From aacf8452d2d0f9c34ae08333db08df6576cf3309 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 27 Apr 2026 10:42:19 -0400 Subject: [PATCH 01/18] ruby: add serde_magnus dep and to_ruby helper --- crates/ruby/Cargo.toml | 1 + crates/ruby/src/lib.rs | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/ruby/Cargo.toml b/crates/ruby/Cargo.toml index 1a7eea3..39024ba 100644 --- a/crates/ruby/Cargo.toml +++ b/crates/ruby/Cargo.toml @@ -19,6 +19,7 @@ magnus = { workspace = true } tokio = { version = "1", features = ["rt-multi-thread"] } serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } +serde_magnus = "0.11" [build-dependencies] rb-sys-build = "0.9" diff --git a/crates/ruby/src/lib.rs b/crates/ruby/src/lib.rs index efc9fe7..e14b102 100644 --- a/crates/ruby/src/lib.rs +++ b/crates/ruby/src/lib.rs @@ -32,8 +32,13 @@ fn parse_err(e: serde_json::Error) -> Error { Error::new(ruby().exception_arg_error(), e.to_string()) } -fn to_json(v: T) -> Result { - serde_json::to_string(&v).map_err(parse_err) +fn to_ruby(v: T) -> Result { + serde_magnus::serialize(&v).map_err(|e| { + Error::new( + ruby().exception_runtime_error(), + format!("response serialization failed: {e}"), + ) + }) } fn parse_enum(s: String) -> Result { From 7aa72b20db021d3622d9e28b0633c51cdd596f44 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 27 Apr 2026 10:45:11 -0400 Subject: [PATCH 02/18] ruby: return native Hash/Array via serde_magnus instead of JSON strings --- crates/ruby/src/lib.rs | 246 ++++++++++++++++++++--------------------- 1 file changed, 123 insertions(+), 123 deletions(-) diff --git a/crates/ruby/src/lib.rs b/crates/ruby/src/lib.rs index e14b102..fd23b8e 100644 --- a/crates/ruby/src/lib.rs +++ b/crates/ruby/src/lib.rs @@ -33,7 +33,7 @@ fn parse_err(e: serde_json::Error) -> Error { } fn to_ruby(v: T) -> Result { - serde_magnus::serialize(&v).map_err(|e| { + serde_magnus::serialize(&ruby(), &v).map_err(|e| { Error::new( ruby().exception_runtime_error(), format!("response serialization failed: {e}"), @@ -276,7 +276,7 @@ pub struct AdminApiClient { #[allow(clippy::needless_pass_by_value)] impl AdminApiClient { - fn get_endpoints(&self, opts: RHash) -> Result { + fn get_endpoints(&self, opts: RHash) -> Result { validate_keys( &opts, &[ @@ -312,10 +312,10 @@ impl AdminApiClient { runtime() .block_on(client.get_endpoints(¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn create_endpoint(&self, opts: RHash) -> Result { + fn create_endpoint(&self, opts: RHash) -> Result { validate_keys(&opts, &["chain", "network"])?; let client = self.inner.clone(); let params = core::admin::CreateEndpointRequest { @@ -325,17 +325,17 @@ impl AdminApiClient { runtime() .block_on(client.create_endpoint(¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn show_endpoint(&self, opts: RHash) -> Result { + fn show_endpoint(&self, opts: RHash) -> Result { validate_keys(&opts, &["id"])?; let client = self.inner.clone(); let id = hash_require_string(&opts, "id")?; runtime() .block_on(client.show_endpoint(&id)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } fn update_endpoint(&self, opts: RHash) -> Result<(), Error> { @@ -359,7 +359,7 @@ impl AdminApiClient { .map_err(map_err) } - fn update_endpoint_status(&self, opts: RHash) -> Result { + fn update_endpoint_status(&self, opts: RHash) -> Result { validate_keys(&opts, &["id", "status"])?; let client = self.inner.clone(); let id = hash_require_string(&opts, "id")?; @@ -369,7 +369,7 @@ impl AdminApiClient { runtime() .block_on(client.update_endpoint_status(&id, ¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } fn create_tag(&self, opts: RHash) -> Result<(), Error> { @@ -394,7 +394,7 @@ impl AdminApiClient { .map_err(map_err) } - fn get_usage(&self, opts: RHash) -> Result { + fn get_usage(&self, opts: RHash) -> Result { validate_keys(&opts, &["start_time", "end_time"])?; let client = self.inner.clone(); let params = core::admin::GetUsageRequest { @@ -404,10 +404,10 @@ impl AdminApiClient { runtime() .block_on(client.get_usage(¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn get_usage_by_endpoint(&self, opts: RHash) -> Result { + fn get_usage_by_endpoint(&self, opts: RHash) -> Result { validate_keys(&opts, &["start_time", "end_time"])?; let client = self.inner.clone(); let params = core::admin::GetUsageRequest { @@ -417,10 +417,10 @@ impl AdminApiClient { runtime() .block_on(client.get_usage_by_endpoint(¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn get_usage_by_method(&self, opts: RHash) -> Result { + fn get_usage_by_method(&self, opts: RHash) -> Result { validate_keys(&opts, &["start_time", "end_time"])?; let client = self.inner.clone(); let params = core::admin::GetUsageRequest { @@ -430,10 +430,10 @@ impl AdminApiClient { runtime() .block_on(client.get_usage_by_method(¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn get_usage_by_chain(&self, opts: RHash) -> Result { + fn get_usage_by_chain(&self, opts: RHash) -> Result { validate_keys(&opts, &["start_time", "end_time"])?; let client = self.inner.clone(); let params = core::admin::GetUsageRequest { @@ -443,10 +443,10 @@ impl AdminApiClient { runtime() .block_on(client.get_usage_by_chain(¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn get_endpoint_logs(&self, opts: RHash) -> Result { + fn get_endpoint_logs(&self, opts: RHash) -> Result { validate_keys( &opts, &[ @@ -470,10 +470,10 @@ impl AdminApiClient { runtime() .block_on(client.get_endpoint_logs(&id, ¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn get_log_details(&self, opts: RHash) -> Result { + fn get_log_details(&self, opts: RHash) -> Result { validate_keys(&opts, &["id", "request_id"])?; let client = self.inner.clone(); let id = hash_require_string(&opts, "id")?; @@ -481,20 +481,20 @@ impl AdminApiClient { runtime() .block_on(client.get_log_details(&id, &request_id)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn get_security_options(&self, opts: RHash) -> Result { + fn get_security_options(&self, opts: RHash) -> Result { validate_keys(&opts, &["id"])?; let client = self.inner.clone(); let id = hash_require_string(&opts, "id")?; runtime() .block_on(client.get_security_options(&id)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn update_security_options(&self, opts: RHash) -> Result { + fn update_security_options(&self, opts: RHash) -> Result { validate_keys( &opts, &[ @@ -528,7 +528,7 @@ impl AdminApiClient { runtime() .block_on(client.update_security_options(&id, ¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } fn create_token(&self, opts: RHash) -> Result<(), Error> { @@ -540,7 +540,7 @@ impl AdminApiClient { .map_err(map_err) } - fn delete_token(&self, opts: RHash) -> Result { + fn delete_token(&self, opts: RHash) -> Result { validate_keys(&opts, &["id", "token_id"])?; let client = self.inner.clone(); let id = hash_require_string(&opts, "id")?; @@ -548,7 +548,7 @@ impl AdminApiClient { runtime() .block_on(client.delete_token(&id, &token_id)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } fn create_referrer(&self, opts: RHash) -> Result<(), Error> { @@ -563,7 +563,7 @@ impl AdminApiClient { .map_err(map_err) } - fn delete_referrer(&self, opts: RHash) -> Result { + fn delete_referrer(&self, opts: RHash) -> Result { validate_keys(&opts, &["id", "referrer_id"])?; let client = self.inner.clone(); let id = hash_require_string(&opts, "id")?; @@ -571,7 +571,7 @@ impl AdminApiClient { runtime() .block_on(client.delete_referrer(&id, &referrer_id)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } fn create_ip(&self, opts: RHash) -> Result<(), Error> { @@ -586,7 +586,7 @@ impl AdminApiClient { .map_err(map_err) } - fn delete_ip(&self, opts: RHash) -> Result { + fn delete_ip(&self, opts: RHash) -> Result { validate_keys(&opts, &["id", "ip_id"])?; let client = self.inner.clone(); let id = hash_require_string(&opts, "id")?; @@ -594,7 +594,7 @@ impl AdminApiClient { runtime() .block_on(client.delete_ip(&id, &ip_id)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } fn create_domain_mask(&self, opts: RHash) -> Result<(), Error> { @@ -609,7 +609,7 @@ impl AdminApiClient { .map_err(map_err) } - fn delete_domain_mask(&self, opts: RHash) -> Result { + fn delete_domain_mask(&self, opts: RHash) -> Result { validate_keys(&opts, &["id", "domain_mask_id"])?; let client = self.inner.clone(); let id = hash_require_string(&opts, "id")?; @@ -617,7 +617,7 @@ impl AdminApiClient { runtime() .block_on(client.delete_domain_mask(&id, &domain_mask_id)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } fn create_jwt(&self, opts: RHash) -> Result<(), Error> { @@ -644,7 +644,7 @@ impl AdminApiClient { .map_err(map_err) } - fn create_request_filter(&self, opts: RHash) -> Result { + fn create_request_filter(&self, opts: RHash) -> Result { validate_keys(&opts, &["id", "methods"])?; let client = self.inner.clone(); let id = hash_require_string(&opts, "id")?; @@ -654,7 +654,7 @@ impl AdminApiClient { runtime() .block_on(client.create_request_filter(&id, ¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } fn update_request_filter(&self, opts: RHash) -> Result<(), Error> { @@ -698,7 +698,7 @@ impl AdminApiClient { .map_err(map_err) } - fn create_or_update_ip_custom_header(&self, opts: RHash) -> Result { + fn create_or_update_ip_custom_header(&self, opts: RHash) -> Result { validate_keys(&opts, &["id", "header_name"])?; let client = self.inner.clone(); let id = hash_require_string(&opts, "id")?; @@ -708,30 +708,30 @@ impl AdminApiClient { runtime() .block_on(client.create_or_update_ip_custom_header(&id, ¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn delete_ip_custom_header(&self, opts: RHash) -> Result { + fn delete_ip_custom_header(&self, opts: RHash) -> Result { validate_keys(&opts, &["id"])?; let client = self.inner.clone(); let id = hash_require_string(&opts, "id")?; runtime() .block_on(client.delete_ip_custom_header(&id)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn get_method_rate_limits(&self, opts: RHash) -> Result { + fn get_method_rate_limits(&self, opts: RHash) -> Result { validate_keys(&opts, &["id"])?; let client = self.inner.clone(); let id = hash_require_string(&opts, "id")?; runtime() .block_on(client.get_method_rate_limits(&id)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn create_method_rate_limit(&self, opts: RHash) -> Result { + fn create_method_rate_limit(&self, opts: RHash) -> Result { validate_keys(&opts, &["id", "interval", "methods", "rate"])?; let client = self.inner.clone(); let id = hash_require_string(&opts, "id")?; @@ -743,10 +743,10 @@ impl AdminApiClient { runtime() .block_on(client.create_method_rate_limit(&id, ¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn update_method_rate_limit(&self, opts: RHash) -> Result { + fn update_method_rate_limit(&self, opts: RHash) -> Result { validate_keys( &opts, &["id", "method_rate_limit_id", "methods", "status", "rate"], @@ -762,7 +762,7 @@ impl AdminApiClient { runtime() .block_on(client.update_method_rate_limit(&id, &method_rate_limit_id, ¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } fn delete_method_rate_limit(&self, opts: RHash) -> Result<(), Error> { @@ -791,7 +791,7 @@ impl AdminApiClient { .map_err(map_err) } - fn get_endpoint_metrics(&self, opts: RHash) -> Result { + fn get_endpoint_metrics(&self, opts: RHash) -> Result { validate_keys(&opts, &["id", "period", "metric"])?; let client = self.inner.clone(); let id = hash_require_string(&opts, "id")?; @@ -802,10 +802,10 @@ impl AdminApiClient { runtime() .block_on(client.get_endpoint_metrics(&id, ¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn get_account_metrics(&self, opts: RHash) -> Result { + fn get_account_metrics(&self, opts: RHash) -> Result { validate_keys(&opts, &["period", "metric", "percentile"])?; let client = self.inner.clone(); let params = core::admin::GetAccountMetricsRequest { @@ -816,42 +816,42 @@ impl AdminApiClient { runtime() .block_on(client.get_account_metrics(¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn list_chains(&self) -> Result { + fn list_chains(&self) -> Result { let client = self.inner.clone(); runtime() .block_on(client.list_chains()) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn list_invoices(&self) -> Result { + fn list_invoices(&self) -> Result { let client = self.inner.clone(); runtime() .block_on(client.list_invoices()) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn list_payments(&self) -> Result { + fn list_payments(&self) -> Result { let client = self.inner.clone(); runtime() .block_on(client.list_payments()) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn list_teams(&self) -> Result { + fn list_teams(&self) -> Result { let client = self.inner.clone(); runtime() .block_on(client.list_teams()) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn create_team(&self, opts: RHash) -> Result { + fn create_team(&self, opts: RHash) -> Result { validate_keys(&opts, &["name"])?; let client = self.inner.clone(); let params = core::admin::CreateTeamRequest { @@ -860,40 +860,40 @@ impl AdminApiClient { runtime() .block_on(client.create_team(¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn get_team(&self, opts: RHash) -> Result { + fn get_team(&self, opts: RHash) -> Result { validate_keys(&opts, &["id"])?; let client = self.inner.clone(); let id = hash_require_i64(&opts, "id")?; runtime() .block_on(client.get_team(id)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn delete_team(&self, opts: RHash) -> Result { + fn delete_team(&self, opts: RHash) -> Result { validate_keys(&opts, &["id"])?; let client = self.inner.clone(); let id = hash_require_i64(&opts, "id")?; runtime() .block_on(client.delete_team(id)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn list_team_endpoints(&self, opts: RHash) -> Result { + fn list_team_endpoints(&self, opts: RHash) -> Result { validate_keys(&opts, &["id"])?; let client = self.inner.clone(); let id = hash_require_i64(&opts, "id")?; runtime() .block_on(client.list_team_endpoints(id)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn update_team_endpoints(&self, opts: RHash) -> Result { + fn update_team_endpoints(&self, opts: RHash) -> Result { validate_keys(&opts, &["id", "endpoint_ids"])?; let client = self.inner.clone(); let id = hash_require_i64(&opts, "id")?; @@ -903,10 +903,10 @@ impl AdminApiClient { runtime() .block_on(client.update_team_endpoints(id, ¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn invite_team_member(&self, opts: RHash) -> Result { + fn invite_team_member(&self, opts: RHash) -> Result { validate_keys(&opts, &["id", "email", "full_name", "role"])?; let client = self.inner.clone(); let id = hash_require_i64(&opts, "id")?; @@ -918,10 +918,10 @@ impl AdminApiClient { runtime() .block_on(client.invite_team_member(id, ¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn remove_team_member(&self, opts: RHash) -> Result { + fn remove_team_member(&self, opts: RHash) -> Result { validate_keys(&opts, &["id", "user_id", "destroy_user"])?; let client = self.inner.clone(); let id = hash_require_i64(&opts, "id")?; @@ -932,10 +932,10 @@ impl AdminApiClient { runtime() .block_on(client.remove_team_member(id, user_id, ¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn resend_team_invite(&self, opts: RHash) -> Result { + fn resend_team_invite(&self, opts: RHash) -> Result { validate_keys(&opts, &["id", "user_id"])?; let client = self.inner.clone(); let id = hash_require_i64(&opts, "id")?; @@ -943,10 +943,10 @@ impl AdminApiClient { runtime() .block_on(client.resend_team_invite(id, user_id)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn bulk_update_endpoint_status(&self, opts: RHash) -> Result { + fn bulk_update_endpoint_status(&self, opts: RHash) -> Result { validate_keys(&opts, &["ids", "status"])?; let client = self.inner.clone(); let params = core::admin::BulkUpdateEndpointStatusRequest { @@ -956,10 +956,10 @@ impl AdminApiClient { runtime() .block_on(client.bulk_update_endpoint_status(¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn bulk_add_tag(&self, opts: RHash) -> Result { + fn bulk_add_tag(&self, opts: RHash) -> Result { validate_keys(&opts, &["ids", "label"])?; let client = self.inner.clone(); let params = core::admin::BulkAddTagRequest { @@ -969,10 +969,10 @@ impl AdminApiClient { runtime() .block_on(client.bulk_add_tag(¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn bulk_remove_tag(&self, opts: RHash) -> Result { + fn bulk_remove_tag(&self, opts: RHash) -> Result { validate_keys(&opts, &["ids", "tag_id"])?; let client = self.inner.clone(); let params = core::admin::BulkRemoveTagRequest { @@ -982,18 +982,18 @@ impl AdminApiClient { runtime() .block_on(client.bulk_remove_tag(¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn list_tags(&self) -> Result { + fn list_tags(&self) -> Result { let client = self.inner.clone(); runtime() .block_on(client.list_tags()) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn rename_tag(&self, opts: RHash) -> Result { + fn rename_tag(&self, opts: RHash) -> Result { validate_keys(&opts, &["id", "label"])?; let client = self.inner.clone(); let id = hash_require_i32(&opts, "id")?; @@ -1003,20 +1003,20 @@ impl AdminApiClient { runtime() .block_on(client.rename_tag(id, ¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn delete_account_tag(&self, opts: RHash) -> Result { + fn delete_account_tag(&self, opts: RHash) -> Result { validate_keys(&opts, &["id"])?; let client = self.inner.clone(); let id = hash_require_i32(&opts, "id")?; runtime() .block_on(client.delete_account_tag(id)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn get_usage_by_tag(&self, opts: RHash) -> Result { + fn get_usage_by_tag(&self, opts: RHash) -> Result { validate_keys(&opts, &["start_time", "end_time"])?; let client = self.inner.clone(); let params = core::admin::GetUsageRequest { @@ -1026,17 +1026,17 @@ impl AdminApiClient { runtime() .block_on(client.get_usage_by_tag(¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn get_endpoint_security(&self, opts: RHash) -> Result { + fn get_endpoint_security(&self, opts: RHash) -> Result { validate_keys(&opts, &["id"])?; let client = self.inner.clone(); let id = hash_require_string(&opts, "id")?; runtime() .block_on(client.get_endpoint_security(&id)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } } @@ -1243,7 +1243,7 @@ impl StreamsApiClient { // create_stream accepts a Ruby Hash because the param count exceeds magnus arity limit of 15. // Required keys: name, network, dataset, region, start_range, end_range, // destination_attributes, plan, threshold_fetch_buffer - fn create_stream(&self, opts: RHash) -> Result { + fn create_stream(&self, opts: RHash) -> Result { let client = self.inner.clone(); let name = hash_require_string(&opts, "name")?; let network = hash_require_string(&opts, "network")?; @@ -1303,10 +1303,10 @@ impl StreamsApiClient { runtime() .block_on(client.create_stream(¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn list_streams(&self, opts: RHash) -> Result { + fn list_streams(&self, opts: RHash) -> Result { validate_keys( &opts, &[ @@ -1328,7 +1328,7 @@ impl StreamsApiClient { runtime() .block_on(client.list_streams(¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } fn delete_all_streams(&self) -> Result<(), Error> { @@ -1338,18 +1338,18 @@ impl StreamsApiClient { .map_err(map_err) } - fn get_stream(&self, opts: RHash) -> Result { + fn get_stream(&self, opts: RHash) -> Result { validate_keys(&opts, &["id"])?; let client = self.inner.clone(); let id = hash_require_string(&opts, "id")?; runtime() .block_on(client.get_stream(&id)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } // update_stream accepts id + a Ruby Hash (opts) because the param count exceeds 15. - fn update_stream(&self, opts: RHash) -> Result { + fn update_stream(&self, opts: RHash) -> Result { let client = self.inner.clone(); let id = hash_require_string(&opts, "id")?; let dataset = @@ -1396,7 +1396,7 @@ impl StreamsApiClient { runtime() .block_on(client.update_stream(&id, ¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } fn delete_stream(&self, opts: RHash) -> Result<(), Error> { @@ -1426,7 +1426,7 @@ impl StreamsApiClient { .map_err(map_err) } - fn test_filter(&self, opts: RHash) -> Result { + fn test_filter(&self, opts: RHash) -> Result { validate_keys( &opts, &[ @@ -1455,17 +1455,17 @@ impl StreamsApiClient { runtime() .block_on(client.test_filter(¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn get_enabled_count(&self, opts: RHash) -> Result { + fn get_enabled_count(&self, opts: RHash) -> Result { validate_keys(&opts, &["stream_type"])?; let client = self.inner.clone(); let stream_type = hash_get_string(&opts, "stream_type")?; runtime() .block_on(client.get_enabled_count(stream_type.as_deref())) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } } @@ -1479,7 +1479,7 @@ pub struct WebhooksApiClient { #[allow(clippy::needless_pass_by_value)] impl WebhooksApiClient { - fn list_webhooks(&self, opts: RHash) -> Result { + fn list_webhooks(&self, opts: RHash) -> Result { validate_keys(&opts, &["limit", "offset"])?; let client = self.inner.clone(); let params = core::webhooks::GetWebhooksParams { @@ -1489,7 +1489,7 @@ impl WebhooksApiClient { runtime() .block_on(client.list_webhooks(¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } fn delete_all_webhooks(&self) -> Result<(), Error> { @@ -1499,17 +1499,17 @@ impl WebhooksApiClient { .map_err(map_err) } - fn get_webhook(&self, opts: RHash) -> Result { + fn get_webhook(&self, opts: RHash) -> Result { validate_keys(&opts, &["id"])?; let client = self.inner.clone(); let id = hash_require_string(&opts, "id")?; runtime() .block_on(client.get_webhook(&id)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn update_webhook(&self, opts: RHash) -> Result { + fn update_webhook(&self, opts: RHash) -> Result { validate_keys( &opts, &[ @@ -1536,7 +1536,7 @@ impl WebhooksApiClient { runtime() .block_on(client.update_webhook(&id, ¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } fn delete_webhook(&self, opts: RHash) -> Result<(), Error> { @@ -1571,15 +1571,15 @@ impl WebhooksApiClient { .map_err(map_err) } - fn get_enabled_count(&self) -> Result { + fn get_enabled_count(&self) -> Result { let client = self.inner.clone(); runtime() .block_on(client.get_enabled_count()) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn create_webhook_from_template(&self, opts: RHash) -> Result { + fn create_webhook_from_template(&self, opts: RHash) -> Result { validate_keys( &opts, &[ @@ -1608,10 +1608,10 @@ impl WebhooksApiClient { runtime() .block_on(client.create_webhook_from_template(¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn update_webhook_template(&self, opts: RHash) -> Result { + fn update_webhook_template(&self, opts: RHash) -> Result { validate_keys( &opts, &[ @@ -1635,7 +1635,7 @@ impl WebhooksApiClient { runtime() .block_on(client.update_webhook_template(&webhook_id, ¶ms)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } } @@ -1660,7 +1660,7 @@ impl KvStoreApiClient { .map_err(map_err) } - fn get_sets(&self, opts: RHash) -> Result { + fn get_sets(&self, opts: RHash) -> Result { validate_keys(&opts, &["limit", "cursor"])?; let client = self.inner.clone(); runtime() @@ -1669,17 +1669,17 @@ impl KvStoreApiClient { cursor: hash_get_string(&opts, "cursor")?, })) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn get_set(&self, opts: RHash) -> Result { + fn get_set(&self, opts: RHash) -> Result { validate_keys(&opts, &["key"])?; let client = self.inner.clone(); let key = hash_require_string(&opts, "key")?; runtime() .block_on(client.get_set(&key)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } fn bulk_sets(&self, opts: RHash) -> Result<(), Error> { @@ -1711,7 +1711,7 @@ impl KvStoreApiClient { .map_err(map_err) } - fn get_lists(&self, opts: RHash) -> Result { + fn get_lists(&self, opts: RHash) -> Result { validate_keys(&opts, &["limit", "cursor"])?; let client = self.inner.clone(); runtime() @@ -1720,10 +1720,10 @@ impl KvStoreApiClient { cursor: hash_get_string(&opts, "cursor")?, })) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } - fn get_list(&self, opts: RHash) -> Result { + fn get_list(&self, opts: RHash) -> Result { validate_keys(&opts, &["key", "limit", "cursor"])?; let client = self.inner.clone(); let key = hash_require_string(&opts, "key")?; @@ -1736,7 +1736,7 @@ impl KvStoreApiClient { }, )) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } fn update_list(&self, opts: RHash) -> Result<(), Error> { @@ -1768,7 +1768,7 @@ impl KvStoreApiClient { .map_err(map_err) } - fn list_contains_item(&self, opts: RHash) -> Result { + fn list_contains_item(&self, opts: RHash) -> Result { validate_keys(&opts, &["key", "item"])?; let client = self.inner.clone(); let key = hash_require_string(&opts, "key")?; @@ -1776,7 +1776,7 @@ impl KvStoreApiClient { runtime() .block_on(client.list_contains_item(&key, &item)) .map_err(map_err) - .and_then(to_json) + .and_then(to_ruby) } fn delete_list_item(&self, opts: RHash) -> Result<(), Error> { From 97a0dd059b0b88fb3c4080c791a488bcedabf31a Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 27 Apr 2026 10:47:45 -0400 Subject: [PATCH 03/18] ruby: move native binding classes under QuicknodeSdk::Native --- crates/ruby/src/lib.rs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/crates/ruby/src/lib.rs b/crates/ruby/src/lib.rs index fd23b8e..92740a3 100644 --- a/crates/ruby/src/lib.rs +++ b/crates/ruby/src/lib.rs @@ -227,7 +227,7 @@ fn hash_get_extra_destinations( // ── QuicknodeSdk ──────────────────────────────────────────────────────────── -#[magnus::wrap(class = "QuicknodeSdk::SDK", free_immediately, size)] +#[magnus::wrap(class = "QuicknodeSdk::Native::SDK", free_immediately, size)] pub struct QuicknodeSdk { inner: core::QuicknodeSdk, } @@ -268,7 +268,7 @@ impl QuicknodeSdk { // // All methods return JSON strings. Call JSON.parse on the result in Ruby. -#[magnus::wrap(class = "QuicknodeSdk::Admin", free_immediately, size)] +#[magnus::wrap(class = "QuicknodeSdk::Native::Admin", free_immediately, size)] #[derive(Clone)] pub struct AdminApiClient { inner: core::admin::AdminApiClient, @@ -1232,7 +1232,7 @@ impl DestinationAttributes { // ── StreamsApiClient ──────────────────────────────────────────────────────── -#[magnus::wrap(class = "QuicknodeSdk::Streams", free_immediately, size)] +#[magnus::wrap(class = "QuicknodeSdk::Native::Streams", free_immediately, size)] #[derive(Clone)] pub struct StreamsApiClient { inner: core::streams::StreamsApiClient, @@ -1471,7 +1471,7 @@ impl StreamsApiClient { // ── WebhooksApiClient ─────────────────────────────────────────────────────── -#[magnus::wrap(class = "QuicknodeSdk::Webhooks", free_immediately, size)] +#[magnus::wrap(class = "QuicknodeSdk::Native::Webhooks", free_immediately, size)] #[derive(Clone)] pub struct WebhooksApiClient { inner: core::webhooks::WebhooksApiClient, @@ -1641,7 +1641,7 @@ impl WebhooksApiClient { // ── KvStoreApiClient ──────────────────────────────────────────────────────── -#[magnus::wrap(class = "QuicknodeSdk::KvStore", free_immediately, size)] +#[magnus::wrap(class = "QuicknodeSdk::Native::KvStore", free_immediately, size)] #[derive(Clone)] pub struct KvStoreApiClient { inner: core::kvstore::KvStoreApiClient, @@ -1809,8 +1809,10 @@ fn init(ruby: &Ruby) -> Result<(), Error> { // can use them from the first call onward. errors::init(ruby, &module)?; + let native = module.define_module("Native")?; + // ── SDK root ────────────────────────────────────────────── - let sdk = module.define_class("SDK", ruby.class_object())?; + let sdk = native.define_class("SDK", ruby.class_object())?; sdk.define_singleton_method("from_env", function!(QuicknodeSdk::from_env, 0))?; sdk.define_method("admin", method!(QuicknodeSdk::admin, 0))?; sdk.define_method("streams", method!(QuicknodeSdk::streams, 0))?; @@ -1818,7 +1820,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> { sdk.define_method("kvstore", method!(QuicknodeSdk::kvstore, 0))?; // ── Admin ───────────────────────────────────────────────── - let admin = module.define_class("Admin", ruby.class_object())?; + let admin = native.define_class("Admin", ruby.class_object())?; admin.define_method("get_endpoints", method!(AdminApiClient::get_endpoints, 1))?; admin.define_method( "create_endpoint", @@ -2016,7 +2018,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> { dest_attrs.define_singleton_method("redis", function!(DestinationAttributes::redis, 1))?; // ── Streams ─────────────────────────────────────────────── - let streams = module.define_class("Streams", ruby.class_object())?; + let streams = native.define_class("Streams", ruby.class_object())?; streams.define_method("create_stream", method!(StreamsApiClient::create_stream, 1))?; streams.define_method("list_streams", method!(StreamsApiClient::list_streams, 1))?; streams.define_method( @@ -2038,7 +2040,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> { )?; // ── Webhooks ────────────────────────────────────────────── - let webhooks = module.define_class("Webhooks", ruby.class_object())?; + let webhooks = native.define_class("Webhooks", ruby.class_object())?; webhooks.define_method( "list_webhooks", method!(WebhooksApiClient::list_webhooks, 1), @@ -2078,7 +2080,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> { )?; // ── KvStore ─────────────────────────────────────────────── - let kvstore = module.define_class("KvStore", ruby.class_object())?; + let kvstore = native.define_class("KvStore", ruby.class_object())?; kvstore.define_method("create_set", method!(KvStoreApiClient::create_set, 1))?; kvstore.define_method("get_sets", method!(KvStoreApiClient::get_sets, 1))?; kvstore.define_method("get_set", method!(KvStoreApiClient::get_set, 1))?; From 9b6e64af547858755717e2892206cdc8b6fda8cb Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 27 Apr 2026 10:49:35 -0400 Subject: [PATCH 04/18] ruby: add hashie runtime dep and broaden gemspec file glob --- ruby/quicknode_sdk.gemspec | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ruby/quicknode_sdk.gemspec b/ruby/quicknode_sdk.gemspec index 77d2b54..da54c1a 100644 --- a/ruby/quicknode_sdk.gemspec +++ b/ruby/quicknode_sdk.gemspec @@ -4,6 +4,7 @@ Gem::Specification.new do |s| s.summary = "Quicknode SDK for Ruby" s.authors = ["Quicknode"] s.license = "MIT" - s.files = ["lib/quicknode_sdk.rb"] + s.files = Dir["lib/**/*.rb"] + ["lib/quicknode_sdk.bundle"] s.required_ruby_version = ">= 3.0" + s.add_runtime_dependency "hashie", "~> 5.0" end From 0d5e4d1cf46d31820ce9e6865f289a9636a9329f Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 27 Apr 2026 10:49:40 -0400 Subject: [PATCH 05/18] ruby: wrap native bindings with Hashie::Mash delegators --- ruby/lib/quicknode_sdk.rb | 6 +++++ ruby/lib/quicknode_sdk/clients/admin.rb | 21 +++++++++++++++++ ruby/lib/quicknode_sdk/clients/kvstore.rb | 21 +++++++++++++++++ ruby/lib/quicknode_sdk/clients/streams.rb | 21 +++++++++++++++++ ruby/lib/quicknode_sdk/clients/webhooks.rb | 21 +++++++++++++++++ ruby/lib/quicknode_sdk/sdk.rb | 27 ++++++++++++++++++++++ ruby/lib/quicknode_sdk/wrap.rb | 11 +++++++++ 7 files changed, 128 insertions(+) create mode 100644 ruby/lib/quicknode_sdk/clients/admin.rb create mode 100644 ruby/lib/quicknode_sdk/clients/kvstore.rb create mode 100644 ruby/lib/quicknode_sdk/clients/streams.rb create mode 100644 ruby/lib/quicknode_sdk/clients/webhooks.rb create mode 100644 ruby/lib/quicknode_sdk/sdk.rb create mode 100644 ruby/lib/quicknode_sdk/wrap.rb diff --git a/ruby/lib/quicknode_sdk.rb b/ruby/lib/quicknode_sdk.rb index ac2a35c..846b02c 100644 --- a/ruby/lib/quicknode_sdk.rb +++ b/ruby/lib/quicknode_sdk.rb @@ -1 +1,7 @@ require_relative "quicknode_sdk.bundle" +require_relative "quicknode_sdk/wrap" +require_relative "quicknode_sdk/clients/admin" +require_relative "quicknode_sdk/clients/streams" +require_relative "quicknode_sdk/clients/webhooks" +require_relative "quicknode_sdk/clients/kvstore" +require_relative "quicknode_sdk/sdk" diff --git a/ruby/lib/quicknode_sdk/clients/admin.rb b/ruby/lib/quicknode_sdk/clients/admin.rb new file mode 100644 index 0000000..695a17d --- /dev/null +++ b/ruby/lib/quicknode_sdk/clients/admin.rb @@ -0,0 +1,21 @@ +module QuicknodeSdk + class Admin + def initialize(native) + @native = native + end + + def method_missing(name, *args, **kwargs) + return super unless @native.respond_to?(name) + result = if kwargs.empty? + @native.public_send(name, *args) + else + @native.public_send(name, **kwargs) + end + QuicknodeSdk.wrap(result) + end + + def respond_to_missing?(name, include_private = false) + @native.respond_to?(name, include_private) || super + end + end +end diff --git a/ruby/lib/quicknode_sdk/clients/kvstore.rb b/ruby/lib/quicknode_sdk/clients/kvstore.rb new file mode 100644 index 0000000..51a1c5e --- /dev/null +++ b/ruby/lib/quicknode_sdk/clients/kvstore.rb @@ -0,0 +1,21 @@ +module QuicknodeSdk + class KvStore + def initialize(native) + @native = native + end + + def method_missing(name, *args, **kwargs) + return super unless @native.respond_to?(name) + result = if kwargs.empty? + @native.public_send(name, *args) + else + @native.public_send(name, **kwargs) + end + QuicknodeSdk.wrap(result) + end + + def respond_to_missing?(name, include_private = false) + @native.respond_to?(name, include_private) || super + end + end +end diff --git a/ruby/lib/quicknode_sdk/clients/streams.rb b/ruby/lib/quicknode_sdk/clients/streams.rb new file mode 100644 index 0000000..f51168f --- /dev/null +++ b/ruby/lib/quicknode_sdk/clients/streams.rb @@ -0,0 +1,21 @@ +module QuicknodeSdk + class Streams + def initialize(native) + @native = native + end + + def method_missing(name, *args, **kwargs) + return super unless @native.respond_to?(name) + result = if kwargs.empty? + @native.public_send(name, *args) + else + @native.public_send(name, **kwargs) + end + QuicknodeSdk.wrap(result) + end + + def respond_to_missing?(name, include_private = false) + @native.respond_to?(name, include_private) || super + end + end +end diff --git a/ruby/lib/quicknode_sdk/clients/webhooks.rb b/ruby/lib/quicknode_sdk/clients/webhooks.rb new file mode 100644 index 0000000..5a9b981 --- /dev/null +++ b/ruby/lib/quicknode_sdk/clients/webhooks.rb @@ -0,0 +1,21 @@ +module QuicknodeSdk + class Webhooks + def initialize(native) + @native = native + end + + def method_missing(name, *args, **kwargs) + return super unless @native.respond_to?(name) + result = if kwargs.empty? + @native.public_send(name, *args) + else + @native.public_send(name, **kwargs) + end + QuicknodeSdk.wrap(result) + end + + def respond_to_missing?(name, include_private = false) + @native.respond_to?(name, include_private) || super + end + end +end diff --git a/ruby/lib/quicknode_sdk/sdk.rb b/ruby/lib/quicknode_sdk/sdk.rb new file mode 100644 index 0000000..b6c218b --- /dev/null +++ b/ruby/lib/quicknode_sdk/sdk.rb @@ -0,0 +1,27 @@ +module QuicknodeSdk + class SDK + def self.from_env + new(Native::SDK.from_env) + end + + def initialize(native) + @native = native + end + + def admin + Admin.new(@native.admin) + end + + def streams + Streams.new(@native.streams) + end + + def webhooks + Webhooks.new(@native.webhooks) + end + + def kvstore + KvStore.new(@native.kvstore) + end + end +end diff --git a/ruby/lib/quicknode_sdk/wrap.rb b/ruby/lib/quicknode_sdk/wrap.rb new file mode 100644 index 0000000..d1d1862 --- /dev/null +++ b/ruby/lib/quicknode_sdk/wrap.rb @@ -0,0 +1,11 @@ +require "hashie" + +module QuicknodeSdk + def self.wrap(v) + case v + when Hash then Hashie::Mash.new(v) + when Array then v.map { |x| wrap(x) } + else v + end + end +end From 215e95861b75f9d75591e75d4eb588ddd414cb1e Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 27 Apr 2026 10:52:11 -0400 Subject: [PATCH 06/18] ruby: update admin.rb example to dot-notation responses --- ruby/examples/admin.rb | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/ruby/examples/admin.rb b/ruby/examples/admin.rb index 3889574..1f59e57 100644 --- a/ruby/examples/admin.rb +++ b/ruby/examples/admin.rb @@ -1,29 +1,28 @@ -require "json" require_relative "../lib/quicknode_sdk" qn = QuicknodeSdk::SDK.from_env -response = JSON.parse(qn.admin.get_endpoints( +response = qn.admin.get_endpoints( limit: 20, sort_by: "created_at", sort_direction: "desc" -)) +) -if response["pagination"] - p = response["pagination"] - puts "#{response["data"].length} of #{p["total"]} (offset #{p["offset"]}, limit #{p["limit"]})" +if response.pagination + p = response.pagination + puts "#{response.data.length} of #{p.total} (offset #{p.offset}, limit #{p.limit})" end -response["data"].each do |ep| - puts "#{ep["id"]} | #{ep["name"]} | #{ep["status"]} | #{ep["network"]} | " \ - "dedicated=#{ep["is_dedicated"]} flat=#{ep["is_flat_rate"]}" +response.data.each do |ep| + puts "#{ep.id} | #{ep.name} | #{ep.status} | #{ep.network} | " \ + "dedicated=#{ep.is_dedicated} flat=#{ep.is_flat_rate}" end -tags = JSON.parse(qn.admin.list_tags) -puts "account tags: #{tags.dig("data", "tags")&.length || 0}" +tags = qn.admin.list_tags +puts "account tags: #{tags.data&.tags&.length || 0}" -first = response["data"].first +first = response.data.first if first - sec = JSON.parse(qn.admin.get_endpoint_security(id: first["id"])) - puts "get_endpoint_security: has_data=#{!sec["data"].nil?}" + sec = qn.admin.get_endpoint_security(id: first.id) + puts "get_endpoint_security: has_data=#{!sec.data.nil?}" end From 2cec85e45feb533f8def37f9ac2d366df250a83c Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 27 Apr 2026 10:53:03 -0400 Subject: [PATCH 07/18] ruby: update admin_e2e.rb example to dot-notation responses --- ruby/examples/admin_e2e.rb | 217 +++++++++++++++++++------------------ 1 file changed, 111 insertions(+), 106 deletions(-) diff --git a/ruby/examples/admin_e2e.rb b/ruby/examples/admin_e2e.rb index 66e33c4..33803e3 100644 --- a/ruby/examples/admin_e2e.rb +++ b/ruby/examples/admin_e2e.rb @@ -1,4 +1,3 @@ -require "json" require "securerandom" require_relative "../lib/quicknode_sdk" @@ -6,73 +5,79 @@ # ── Read-only globals ───────────────────────────────────────────────────────── -resp = JSON.parse(qn.admin.list_chains) -puts "list_chains: #{resp["data"].length} chains" +resp = qn.admin.list_chains +puts "list_chains: #{resp.data.length} chains" -resp = JSON.parse(qn.admin.get_endpoints(limit: 5)) -puts "get_endpoints: #{resp["data"].length} endpoints" +resp = qn.admin.get_endpoints(limit: 5) +puts "get_endpoints: #{resp.data.length} endpoints" -resp = JSON.parse(qn.admin.get_usage({})) -puts "get_usage: #{resp["data"].inspect}" +# Verify all three access styles work on a sample response. +sample = qn.admin.get_endpoints(limit: 1) +raise "expected Hashie::Mash" unless sample.is_a?(Hashie::Mash) +raise "indifferent access broken" unless sample.data == sample[:data] && sample[:data] == sample["data"] +puts "indifferent access: ok" + +resp = qn.admin.get_usage({}) +puts "get_usage: #{resp.data.inspect}" sleep 0.5 -resp = JSON.parse(qn.admin.get_usage_by_endpoint({})) -puts "get_usage_by_endpoint: #{resp["data"].inspect}" +resp = qn.admin.get_usage_by_endpoint({}) +puts "get_usage_by_endpoint: #{resp.data.inspect}" -resp = JSON.parse(qn.admin.get_usage_by_method({})) -puts "get_usage_by_method: #{resp["data"].inspect}" +resp = qn.admin.get_usage_by_method({}) +puts "get_usage_by_method: #{resp.data.inspect}" sleep 0.5 -resp = JSON.parse(qn.admin.get_usage_by_chain({})) -puts "get_usage_by_chain: #{resp["data"].inspect}" +resp = qn.admin.get_usage_by_chain({}) +puts "get_usage_by_chain: #{resp.data.inspect}" -resp = JSON.parse(qn.admin.get_usage_by_tag({})) -puts "get_usage_by_tag: #{resp["data"].inspect}" +resp = qn.admin.get_usage_by_tag({}) +puts "get_usage_by_tag: #{resp.data.inspect}" -resp = JSON.parse(qn.admin.list_tags) -puts "list_tags: #{resp.dig("data", "tags")&.length || 0} tags" +resp = qn.admin.list_tags +puts "list_tags: #{resp.data&.tags&.length || 0} tags" -resp = JSON.parse(qn.admin.get_account_metrics(period: "day", metric: "requests")) -puts "get_account_metrics: #{resp["data"].length} series" +resp = qn.admin.get_account_metrics(period: "day", metric: "requests") +puts "get_account_metrics: #{resp.data.length} series" -resp = JSON.parse(qn.admin.list_invoices) -puts "list_invoices: #{resp["data"].inspect}" +resp = qn.admin.list_invoices +puts "list_invoices: #{resp.data.inspect}" -resp = JSON.parse(qn.admin.list_payments) -puts "list_payments: #{resp["data"].inspect}" +resp = qn.admin.list_payments +puts "list_payments: #{resp.data.inspect}" -resp = JSON.parse(qn.admin.list_teams) -puts "list_teams: #{resp["data"].length} teams" +resp = qn.admin.list_teams +puts "list_teams: #{resp.data.length} teams" sleep 0.5 # ── Create endpoint ─────────────────────────────────────────────────────────── -resp = JSON.parse(qn.admin.create_endpoint(chain: "ethereum", network: "mainnet")) -endpoint_id = resp.dig("data", "id") +resp = qn.admin.create_endpoint(chain: "ethereum", network: "mainnet") +endpoint_id = resp.data&.id unless endpoint_id - puts "create_endpoint failed: #{resp["error"]}" + puts "create_endpoint failed: #{resp.error}" exit 1 end -puts "create_endpoint: #{endpoint_id} (#{resp.dig("data", "http_url")})" +puts "create_endpoint: #{endpoint_id} (#{resp.data.http_url})" # ── Endpoint CRUD ───────────────────────────────────────────────────────────── -resp = JSON.parse(qn.admin.show_endpoint(id: endpoint_id)) -puts "show_endpoint: #{resp.dig("data", "id")}" +resp = qn.admin.show_endpoint(id: endpoint_id) +puts "show_endpoint: #{resp.data.id}" qn.admin.update_endpoint(id: endpoint_id, label: "sdk-example") puts "update_endpoint: ok" sleep 0.5 -resp = JSON.parse(qn.admin.update_endpoint_status(id: endpoint_id, status: "paused")) -puts "update_endpoint_status paused: #{resp["data"]}" +resp = qn.admin.update_endpoint_status(id: endpoint_id, status: "paused") +puts "update_endpoint_status paused: #{resp.data}" -resp = JSON.parse(qn.admin.update_endpoint_status(id: endpoint_id, status: "active")) -puts "update_endpoint_status active: #{resp["data"]}" +resp = qn.admin.update_endpoint_status(id: endpoint_id, status: "active") +puts "update_endpoint_status active: #{resp.data}" sleep 1 @@ -82,8 +87,8 @@ puts "create_tag: ok" sleep 0.5 -resp = JSON.parse(qn.admin.show_endpoint(id: endpoint_id)) -tag_id = resp.dig("data", "tags", 0, "tag_id")&.to_s +resp = qn.admin.show_endpoint(id: endpoint_id) +tag_id = resp.data&.tags&.first&.tag_id&.to_s if tag_id qn.admin.delete_tag(id: endpoint_id, tag_id: tag_id) puts "delete_tag: ok" @@ -91,30 +96,30 @@ # ── Logs & metrics ──────────────────────────────────────────────────────────── -resp = JSON.parse(qn.admin.get_endpoint_logs( +resp = qn.admin.get_endpoint_logs( id: endpoint_id, from_time: "2025-01-01T00:00:00Z", to_time: "2025-01-02T00:00:00Z" -)) -puts "get_endpoint_logs: #{resp["data"].length} entries" +) +puts "get_endpoint_logs: #{resp.data.length} entries" sleep 1 -resp = JSON.parse(qn.admin.get_endpoint_metrics(id: endpoint_id, period: "day", metric: "credits_over_time")) -puts "get_endpoint_metrics: #{resp["data"].length} series" +resp = qn.admin.get_endpoint_metrics(id: endpoint_id, period: "day", metric: "credits_over_time") +puts "get_endpoint_metrics: #{resp.data.length} series" # ── Security options ────────────────────────────────────────────────────────── -resp = JSON.parse(qn.admin.get_security_options(id: endpoint_id)) -puts "get_security_options: #{resp["data"].length} options" +resp = qn.admin.get_security_options(id: endpoint_id) +puts "get_security_options: #{resp.data.length} options" sleep 1 -resp = JSON.parse(qn.admin.get_endpoint_security(id: endpoint_id)) -puts "get_endpoint_security: has_data=#{!resp["data"].nil?}" +resp = qn.admin.get_endpoint_security(id: endpoint_id) +puts "get_endpoint_security: has_data=#{!resp.data.nil?}" -resp = JSON.parse(qn.admin.update_security_options(id: endpoint_id, tokens: "enabled")) -puts "update_security_options: #{resp["data"].length} options" +resp = qn.admin.update_security_options(id: endpoint_id, tokens: "enabled") +puts "update_security_options: #{resp.data.length} options" sleep 0.5 @@ -124,11 +129,11 @@ qn.admin.create_token(id: endpoint_id) puts "create_token: ok" -resp = JSON.parse(qn.admin.show_endpoint(id: endpoint_id)) -token_id = resp.dig("data", "security", "tokens", 0, "id") +resp = qn.admin.show_endpoint(id: endpoint_id) +token_id = resp.data&.security&.tokens&.first&.id if token_id - resp = JSON.parse(qn.admin.delete_token(id: endpoint_id, token_id: token_id)) - puts "delete_token: #{resp["data"]}" + resp = qn.admin.delete_token(id: endpoint_id, token_id: token_id) + puts "delete_token: #{resp.data}" end # ── Referrer ────────────────────────────────────────────────────────────────── @@ -137,11 +142,11 @@ puts "create_referrer: ok" sleep 0.5 -resp = JSON.parse(qn.admin.show_endpoint(id: endpoint_id)) -referrer_id = resp.dig("data", "security", "referrers", 0, "id") +resp = qn.admin.show_endpoint(id: endpoint_id) +referrer_id = resp.data&.security&.referrers&.first&.id if referrer_id - resp = JSON.parse(qn.admin.delete_referrer(id: endpoint_id, referrer_id: referrer_id)) - puts "delete_referrer: #{resp["data"]}" + resp = qn.admin.delete_referrer(id: endpoint_id, referrer_id: referrer_id) + puts "delete_referrer: #{resp.data}" end # ── IP allowlist ────────────────────────────────────────────────────────────── @@ -149,13 +154,13 @@ qn.admin.create_ip(id: endpoint_id, ip: "192.0.2.1") puts "create_ip: ok" -resp = JSON.parse(qn.admin.show_endpoint(id: endpoint_id)) -ip_id = resp.dig("data", "security", "ips", 0, "id") +resp = qn.admin.show_endpoint(id: endpoint_id) +ip_id = resp.data&.security&.ips&.first&.id sleep 0.5 if ip_id - resp = JSON.parse(qn.admin.delete_ip(id: endpoint_id, ip_id: ip_id)) - puts "delete_ip: #{resp["data"]}" + resp = qn.admin.delete_ip(id: endpoint_id, ip_id: ip_id) + puts "delete_ip: #{resp.data}" end # ── Domain mask ─────────────────────────────────────────────────────────────── @@ -163,11 +168,11 @@ qn.admin.create_domain_mask(id: endpoint_id, domain_mask: "example.com") puts "create_domain_mask: ok" -resp = JSON.parse(qn.admin.show_endpoint(id: endpoint_id)) -mask_id = resp.dig("data", "security", "domain_masks", 0, "id") +resp = qn.admin.show_endpoint(id: endpoint_id) +mask_id = resp.data&.security&.domain_masks&.first&.id if mask_id - resp = JSON.parse(qn.admin.delete_domain_mask(id: endpoint_id, domain_mask_id: mask_id)) - puts "delete_domain_mask: #{resp["data"]}" + resp = qn.admin.delete_domain_mask(id: endpoint_id, domain_mask_id: mask_id) + puts "delete_domain_mask: #{resp.data}" end # ── JWT (placeholder key will fail) ────────────────────────────────────────── @@ -184,8 +189,8 @@ puts "create_jwt error (expected with placeholder key): #{e}" end -resp = JSON.parse(qn.admin.show_endpoint(id: endpoint_id)) -jwt_id = resp.dig("data", "security", "jwts", 0, "id") +resp = qn.admin.show_endpoint(id: endpoint_id) +jwt_id = resp.data&.security&.jwts&.first&.id if jwt_id qn.admin.delete_jwt(id: endpoint_id, jwt_id: jwt_id) puts "delete_jwt: ok" @@ -193,9 +198,9 @@ # ── Request filter ──────────────────────────────────────────────────────────── -resp = JSON.parse(qn.admin.create_request_filter(id: endpoint_id, methods: ["eth_getBalance"])) -rf_id = resp.dig("data", "id") -puts "create_request_filter: #{resp["data"]}" +resp = qn.admin.create_request_filter(id: endpoint_id, methods: ["eth_getBalance"]) +rf_id = resp.data&.id +puts "create_request_filter: #{resp.data}" if rf_id qn.admin.update_request_filter(id: endpoint_id, request_filter_id: rf_id, methods: ["eth_call"]) @@ -207,11 +212,11 @@ # ── IP custom header ────────────────────────────────────────────────────────── -resp = JSON.parse(qn.admin.create_or_update_ip_custom_header(id: endpoint_id, header_name: "X-Custom-Header")) -puts "create_or_update_ip_custom_header: #{resp["data"]}" +resp = qn.admin.create_or_update_ip_custom_header(id: endpoint_id, header_name: "X-Custom-Header") +puts "create_or_update_ip_custom_header: #{resp.data}" -resp = JSON.parse(qn.admin.delete_ip_custom_header(id: endpoint_id)) -puts "delete_ip_custom_header: #{resp["data"]}" +resp = qn.admin.delete_ip_custom_header(id: endpoint_id) +puts "delete_ip_custom_header: #{resp.data}" sleep 0.5 @@ -220,16 +225,16 @@ #qn.admin.update_rate_limits(id: endpoint_id, rps: 3) #puts "update_rate_limits: ok" -resp = JSON.parse(qn.admin.get_method_rate_limits(id: endpoint_id)) -puts "get_method_rate_limits: #{resp["data"]}" +resp = qn.admin.get_method_rate_limits(id: endpoint_id) +puts "get_method_rate_limits: #{resp.data}" -resp = JSON.parse(qn.admin.create_method_rate_limit(id: endpoint_id, interval: "second", methods: ["eth_call"], rate: 5)) -mrl_id = resp.dig("data", "id") -puts "create_method_rate_limit: #{resp["data"]}" +resp = qn.admin.create_method_rate_limit(id: endpoint_id, interval: "second", methods: ["eth_call"], rate: 5) +mrl_id = resp.data&.id +puts "create_method_rate_limit: #{resp.data}" if mrl_id - resp = JSON.parse(qn.admin.update_method_rate_limit(id: endpoint_id, method_rate_limit_id: mrl_id, rate: 10)) - puts "update_method_rate_limit: #{resp["data"]}" + resp = qn.admin.update_method_rate_limit(id: endpoint_id, method_rate_limit_id: mrl_id, rate: 10) + puts "update_method_rate_limit: #{resp.data}" qn.admin.delete_method_rate_limit(id: endpoint_id, method_rate_limit_id: mrl_id) puts "delete_method_rate_limit: ok" @@ -249,66 +254,66 @@ # ── Bulk endpoint ops (single-endpoint batch) ──────────────────────────────── -resp = JSON.parse(qn.admin.bulk_update_endpoint_status(ids: [endpoint_id], status: "paused")) -puts "bulk_update_endpoint_status paused: #{resp["data"]}" +resp = qn.admin.bulk_update_endpoint_status(ids: [endpoint_id], status: "paused") +puts "bulk_update_endpoint_status paused: #{resp.data}" -resp = JSON.parse(qn.admin.bulk_update_endpoint_status(ids: [endpoint_id], status: "active")) -puts "bulk_update_endpoint_status active: #{resp["data"]}" +resp = qn.admin.bulk_update_endpoint_status(ids: [endpoint_id], status: "active") +puts "bulk_update_endpoint_status active: #{resp.data}" # ── Account-level tags (bulk_add/remove + rename/delete) ───────────────────── tag_suffix = SecureRandom.hex(4) -resp = JSON.parse(qn.admin.bulk_add_tag(ids: [endpoint_id], label: "sdk-bulk-#{tag_suffix}")) -puts "bulk_add_tag: #{resp["data"]}" -bulk_tag_id = resp.dig("data", "tag", "tag_id") +resp = qn.admin.bulk_add_tag(ids: [endpoint_id], label: "sdk-bulk-#{tag_suffix}") +puts "bulk_add_tag: #{resp.data}" +bulk_tag_id = resp.data&.tag&.tag_id sleep 0.5 if bulk_tag_id - resp = JSON.parse(qn.admin.rename_tag(id: bulk_tag_id, label: "sdk-renamed-#{tag_suffix}")) - puts "rename_tag: #{resp["data"]}" + resp = qn.admin.rename_tag(id: bulk_tag_id, label: "sdk-renamed-#{tag_suffix}") + puts "rename_tag: #{resp.data}" - resp = JSON.parse(qn.admin.bulk_remove_tag(ids: [endpoint_id], tag_id: bulk_tag_id)) - puts "bulk_remove_tag: #{resp["data"]}" + resp = qn.admin.bulk_remove_tag(ids: [endpoint_id], tag_id: bulk_tag_id) + puts "bulk_remove_tag: #{resp.data}" - resp = JSON.parse(qn.admin.delete_account_tag(id: bulk_tag_id)) - puts "delete_account_tag: #{resp["data"]}" + resp = qn.admin.delete_account_tag(id: bulk_tag_id) + puts "delete_account_tag: #{resp.data}" end sleep 0.5 # ── Teams ───────────────────────────────────────────────────────────────────── -resp = JSON.parse(qn.admin.create_team(name: "sdk-example-team")) -team_id = resp.dig("data", "id") -puts "create_team: #{resp["data"]}" +resp = qn.admin.create_team(name: "sdk-example-team") +team_id = resp.data&.id +puts "create_team: #{resp.data}" sleep 0.5 if team_id - resp = JSON.parse(qn.admin.get_team(id: team_id)) - puts "get_team: #{resp.dig("data", "name")}" + resp = qn.admin.get_team(id: team_id) + puts "get_team: #{resp.data.name}" - resp = JSON.parse(qn.admin.list_team_endpoints(id: team_id)) - puts "list_team_endpoints: #{resp["data"].length} endpoints" + resp = qn.admin.list_team_endpoints(id: team_id) + puts "list_team_endpoints: #{resp.data.length} endpoints" sleep 0.5 - resp = JSON.parse(qn.admin.update_team_endpoints(id: team_id, endpoint_ids: [endpoint_id])) - puts "update_team_endpoints: #{resp["data"]}" + resp = qn.admin.update_team_endpoints(id: team_id, endpoint_ids: [endpoint_id]) + puts "update_team_endpoints: #{resp.data}" sleep 0.5 begin - resp = JSON.parse(qn.admin.invite_team_member(id: team_id, email: "placeholder@example.com")) - puts "invite_team_member: #{resp["data"]}" + resp = qn.admin.invite_team_member(id: team_id, email: "placeholder@example.com") + puts "invite_team_member: #{resp.data}" rescue => e puts "invite_team_member error (expected with placeholder email): #{e}" end sleep 0.5 - resp = JSON.parse(qn.admin.delete_team(id: team_id)) - puts "delete_team: #{resp["data"]}" + resp = qn.admin.delete_team(id: team_id) + puts "delete_team: #{resp.data}" end sleep 0.5 From 13ed76d6883f50432906408f71c0a721712bb0e6 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 27 Apr 2026 10:53:19 -0400 Subject: [PATCH 08/18] ruby: update streams.rb example to dot-notation responses --- ruby/examples/streams.rb | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/ruby/examples/streams.rb b/ruby/examples/streams.rb index 1648271..0170578 100644 --- a/ruby/examples/streams.rb +++ b/ruby/examples/streams.rb @@ -1,4 +1,3 @@ -require "json" require_relative "../lib/quicknode_sdk" qn = QuicknodeSdk::SDK.from_env @@ -11,7 +10,7 @@ compression: "none" ) -stream_json = qn.streams.create_stream({ +stream = qn.streams.create_stream( name: "My Stream", network: "ethereum-mainnet", dataset: "block", @@ -27,7 +26,6 @@ keep_distance_from_tip: 0, elastic_batch_enabled: true, status: "active" -}) +) -stream = JSON.parse(stream_json) -puts "#{stream["id"]} | #{stream["name"]} | #{stream["status"]}" +puts "#{stream.id} | #{stream.name} | #{stream.status}" From 9b55ec871a87b3d582999a38a63c12af6862ef7d Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 27 Apr 2026 10:53:38 -0400 Subject: [PATCH 09/18] ruby: update streams_e2e.rb example to dot-notation responses --- ruby/examples/streams_e2e.rb | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/ruby/examples/streams_e2e.rb b/ruby/examples/streams_e2e.rb index 46f424b..6a249ee 100644 --- a/ruby/examples/streams_e2e.rb +++ b/ruby/examples/streams_e2e.rb @@ -1,21 +1,20 @@ -require "json" require_relative "../lib/quicknode_sdk" qn = QuicknodeSdk::SDK.from_env -before = JSON.parse(qn.streams.list_streams({})) -puts "streams before: #{before["pageInfo"]["total"]}" +before = qn.streams.list_streams({}) +puts "streams before: #{before.pageInfo.total}" -count = JSON.parse(qn.streams.get_enabled_count({})) -puts "enabled count: #{count["total"]}" +count = qn.streams.get_enabled_count({}) +puts "enabled count: #{count.total}" -filter_result = JSON.parse(qn.streams.test_filter( +filter_result = qn.streams.test_filter( network: "ethereum-mainnet", dataset: "block", block: "17811625", filter_function: "ZnVuY3Rpb24gbWFpbihkYXRhKSB7IHJldHVybiBkYXRhOyB9" -)) -puts "filter logs: #{filter_result["logs"]}" +) +puts "filter logs: #{filter_result.logs}" sleep 1 dest = QuicknodeSdk::DestinationAttributes.webhook( @@ -34,7 +33,7 @@ compression: "none" ) -stream = JSON.parse(qn.streams.create_stream({ +stream = qn.streams.create_stream( name: "E2E Test Stream", network: "ethereum-mainnet", dataset: "block", @@ -51,15 +50,15 @@ keep_distance_from_tip: 0, elastic_batch_enabled: true, status: "active" -})) -stream_id = stream["id"] -puts "created: #{stream_id} | #{stream["status"]}" +) +stream_id = stream.id +puts "created: #{stream_id} | #{stream.status}" -fetched = JSON.parse(qn.streams.get_stream(id: stream_id)) -puts "fetched: #{fetched["id"]} | #{fetched["name"]}" +fetched = qn.streams.get_stream(id: stream_id) +puts "fetched: #{fetched.id} | #{fetched.name}" -updated = JSON.parse(qn.streams.update_stream(id: stream_id, name: "E2E Test Stream Updated")) -puts "updated name: #{updated["name"]}" +updated = qn.streams.update_stream(id: stream_id, name: "E2E Test Stream Updated") +puts "updated name: #{updated.name}" sleep 1 qn.streams.pause_stream(id: stream_id) @@ -72,5 +71,5 @@ puts "deleted: #{stream_id}" sleep 1 -after = JSON.parse(qn.streams.list_streams({})) -puts "streams after: #{after["pageInfo"]["total"]}" +after = qn.streams.list_streams({}) +puts "streams after: #{after.pageInfo.total}" From dc09b55aaf3d0c7944cf10923bc23d3c489cec0c Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 27 Apr 2026 10:53:57 -0400 Subject: [PATCH 10/18] ruby: update webhooks_e2e.rb example to dot-notation responses --- ruby/examples/webhooks_e2e.rb | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/ruby/examples/webhooks_e2e.rb b/ruby/examples/webhooks_e2e.rb index d008f80..a398df8 100644 --- a/ruby/examples/webhooks_e2e.rb +++ b/ruby/examples/webhooks_e2e.rb @@ -3,11 +3,11 @@ qn = QuicknodeSdk::SDK.from_env -before = JSON.parse(qn.webhooks.list_webhooks({})) -puts "webhooks before: #{before["data"].length} (total=#{before["pageInfo"]["total"]})" +before = qn.webhooks.list_webhooks({}) +puts "webhooks before: #{before.data.length} (total=#{before.pageInfo.total})" -count = JSON.parse(qn.webhooks.get_enabled_count) -puts "enabled count: #{count["total"]}" +count = qn.webhooks.get_enabled_count +puts "enabled count: #{count.total}" destination_attributes = JSON.generate({ url: "https://webhook.site/ae19071a-2dcc-4035-9cdf-406dcb4719ef", @@ -19,20 +19,20 @@ templateArgs: { wallets: ["0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"] } }) -webhook = JSON.parse(qn.webhooks.create_webhook_from_template( +webhook = qn.webhooks.create_webhook_from_template( name: "E2E Test Webhook", network: "ethereum-mainnet", destination_attributes_json: destination_attributes, template_args_json: template_args -)) -id = webhook["id"] -puts "created: #{id} | #{webhook["status"]}" +) +id = webhook.id +puts "created: #{id} | #{webhook.status}" -fetched = JSON.parse(qn.webhooks.get_webhook(id: id)) -puts "fetched: #{fetched["id"]} | #{fetched["name"]}" +fetched = qn.webhooks.get_webhook(id: id) +puts "fetched: #{fetched.id} | #{fetched.name}" -updated = JSON.parse(qn.webhooks.update_webhook(id: id, name: "E2E Test Webhook Updated")) -puts "updated name: #{updated["name"]}" +updated = qn.webhooks.update_webhook(id: id, name: "E2E Test Webhook Updated") +puts "updated name: #{updated.name}" qn.webhooks.pause_webhook(id: id) puts "paused" @@ -44,5 +44,5 @@ puts "deleted: #{id}" sleep 1 -after = JSON.parse(qn.webhooks.list_webhooks({})) -puts "webhooks after: #{after["data"].length} (total=#{after["pageInfo"]["total"]})" +after = qn.webhooks.list_webhooks({}) +puts "webhooks after: #{after.data.length} (total=#{after.pageInfo.total})" From 819a43cc1b8354a537e266eb15553cbe2678713b Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 27 Apr 2026 10:54:16 -0400 Subject: [PATCH 11/18] ruby: update kvstore_e2e.rb example to dot-notation responses --- ruby/examples/kvstore_e2e.rb | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/ruby/examples/kvstore_e2e.rb b/ruby/examples/kvstore_e2e.rb index 8a179f0..0fe44e6 100644 --- a/ruby/examples/kvstore_e2e.rb +++ b/ruby/examples/kvstore_e2e.rb @@ -1,4 +1,3 @@ -require "json" require_relative "../lib/quicknode_sdk" qn = QuicknodeSdk::SDK.from_env @@ -8,11 +7,11 @@ qn.kvstore.create_set(key: "e2e-set-key", value: "e2e-value") puts "created set: e2e-set-key" -set = JSON.parse(qn.kvstore.get_set(key: "e2e-set-key")) -puts "get set: #{set["value"]}" +set = qn.kvstore.get_set(key: "e2e-set-key") +puts "get set: #{set.value}" -sets = JSON.parse(qn.kvstore.get_sets({})) -puts "all sets: #{sets["data"].map { |e| e["key"] }.inspect}" +sets = qn.kvstore.get_sets({}) +puts "all sets: #{sets.data.map(&:key).inspect}" qn.kvstore.bulk_sets(delete_sets: ["e2e-set-key"]) puts "bulk sets: deleted e2e-set-key" @@ -28,17 +27,18 @@ qn.kvstore.create_list(key: "e2e-list-key", items: ["0xabc", "0xdef"]) puts "created list: e2e-list-key" -list = JSON.parse(qn.kvstore.get_list(key: "e2e-list-key")) -puts "get list items: #{list["data"]["items"].inspect}" +list = qn.kvstore.get_list(key: "e2e-list-key") +puts "get list items: #{list.data.items.inspect}" -lists = JSON.parse(qn.kvstore.get_lists({})) -puts "all list keys: #{lists["data"]["keys"].inspect}" +# Note: data.keys would call Hash#keys (Mash subclasses Hash); use string access for the API field. +lists = qn.kvstore.get_lists({}) +puts "all list keys: #{lists.data["keys"].inspect}" qn.kvstore.add_list_item(key: "e2e-list-key", item: "0x123") puts "added list item: 0x123" -contains = JSON.parse(qn.kvstore.list_contains_item(key: "e2e-list-key", item: "0x123")) -puts "list contains 0x123: #{contains["exists"]}" +contains = qn.kvstore.list_contains_item(key: "e2e-list-key", item: "0x123") +puts "list contains 0x123: #{contains.exists}" qn.kvstore.update_list(key: "e2e-list-key", add_items: ["0x456"], remove_items: ["0xabc"]) puts "updated list: added 0x456, removed 0xabc" From ddb39157fe73b5317d5cb9e8a4e42fe3a098ee6d Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 27 Apr 2026 10:59:43 -0400 Subject: [PATCH 12/18] docs: update README Ruby snippets to dot-notation responses --- README.md | 129 +++++++++++++++++++++++++++--------------------------- 1 file changed, 64 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 78762fb..9ccd505 100644 --- a/README.md +++ b/README.md @@ -116,12 +116,11 @@ console.log(`${resp.data.length} endpoints`); ```ruby # Ruby -require "json" require "quicknode_sdk" qn = QuicknodeSdk::SDK.from_env -resp = JSON.parse(qn.admin.get_endpoints({})) -puts "#{resp["data"].length} endpoints" +resp = qn.admin.get_endpoints +puts "#{resp.data.length} endpoints" ``` ## Configuration @@ -187,7 +186,7 @@ Each method below shows the call pattern in Rust, Python, Node.js, and Ruby in t - **Rust**: methods are `async` and return `Result`. Request structs use the [`bon`](https://docs.rs/bon) builder pattern via `::builder()`. - **Python**: methods are `async` — call with `await`. Parameters are kwargs; responses are native `pyclass` objects with attribute access. - **Node.js**: methods are `async` and take a single options object with camelCase keys. -- **Ruby**: methods are **blocking** (not async). Parameters are a single Hash with symbol keys. Responses that carry data are returned as **JSON strings** — wrap calls with `JSON.parse`. Unknown keys raise `ArgumentError`. +- **Ruby**: methods are **blocking** (not async). Parameters are a single Hash with symbol keys. Responses that carry data are returned as `Hashie::Mash` — access fields via dot-notation (`resp.data.first.id`), symbol keys (`resp[:data]`), or string keys (`resp["data"]`). Unknown keys raise `ArgumentError`. --- @@ -231,7 +230,7 @@ const resp = await qn.admin.getEndpoints({ ```ruby # Ruby -resp = JSON.parse(qn.admin.get_endpoints(limit: 20, sort_by: "created_at", sort_direction: "desc")) +resp = qn.admin.get_endpoints(limit: 20, sort_by: "created_at", sort_direction: "desc") ``` ##### `create_endpoint` / `createEndpoint` @@ -263,7 +262,7 @@ const resp = await qn.admin.createEndpoint({ chain: "ethereum", network: "mainne ```ruby # Ruby -resp = JSON.parse(qn.admin.create_endpoint(chain: "ethereum", network: "mainnet")) +resp = qn.admin.create_endpoint(chain: "ethereum", network: "mainnet") ``` ##### `show_endpoint` / `showEndpoint` @@ -291,7 +290,7 @@ const resp = await qn.admin.showEndpoint("ep-123"); ```ruby # Ruby -resp = JSON.parse(qn.admin.show_endpoint(id: "ep-123")) +resp = qn.admin.show_endpoint(id: "ep-123") ``` ##### `update_endpoint` / `updateEndpoint` @@ -377,7 +376,7 @@ await qn.admin.updateEndpointStatus("ep-123", { status: "paused" }); ```ruby # Ruby -JSON.parse(qn.admin.update_endpoint_status(id: "ep-123", status: "paused")) +qn.admin.update_endpoint_status(id: "ep-123", status: "paused") ``` #### Endpoint Tags @@ -468,7 +467,7 @@ const resp = await qn.admin.listTeams(); ```ruby # Ruby -resp = JSON.parse(qn.admin.list_teams) +resp = qn.admin.list_teams ``` ##### `create_team` / `createTeam` @@ -497,7 +496,7 @@ const resp = await qn.admin.createTeam({ name: "Payments" }); ```ruby # Ruby -resp = JSON.parse(qn.admin.create_team(name: "Payments")) +resp = qn.admin.create_team(name: "Payments") ``` ##### `get_team` / `getTeam` @@ -525,7 +524,7 @@ const resp = await qn.admin.getTeam(42); ```ruby # Ruby -resp = JSON.parse(qn.admin.get_team(id: 42)) +resp = qn.admin.get_team(id: 42) ``` ##### `delete_team` / `deleteTeam` @@ -581,7 +580,7 @@ const resp = await qn.admin.listTeamEndpoints(42); ```ruby # Ruby -resp = JSON.parse(qn.admin.list_team_endpoints(id: 42)) +resp = qn.admin.list_team_endpoints(id: 42) ``` ##### `update_team_endpoints` / `updateTeamEndpoints` @@ -730,7 +729,7 @@ const resp = await qn.admin.getUsage(); ```ruby # Ruby -resp = JSON.parse(qn.admin.get_usage({})) +resp = qn.admin.get_usage({}) ``` ##### `get_usage_by_endpoint` / `getUsageByEndpoint` @@ -756,7 +755,7 @@ const resp = await qn.admin.getUsageByEndpoint(); ```ruby # Ruby -resp = JSON.parse(qn.admin.get_usage_by_endpoint({})) +resp = qn.admin.get_usage_by_endpoint({}) ``` ##### `get_usage_by_method` / `getUsageByMethod` @@ -782,7 +781,7 @@ const resp = await qn.admin.getUsageByMethod(); ```ruby # Ruby -resp = JSON.parse(qn.admin.get_usage_by_method({})) +resp = qn.admin.get_usage_by_method({}) ``` ##### `get_usage_by_chain` / `getUsageByChain` @@ -808,7 +807,7 @@ const resp = await qn.admin.getUsageByChain(); ```ruby # Ruby -resp = JSON.parse(qn.admin.get_usage_by_chain({})) +resp = qn.admin.get_usage_by_chain({}) ``` ##### `get_usage_by_tag` / `getUsageByTag` @@ -834,7 +833,7 @@ const resp = await qn.admin.getUsageByTag(); ```ruby # Ruby -resp = JSON.parse(qn.admin.get_usage_by_tag({})) +resp = qn.admin.get_usage_by_tag({}) ``` #### Logs @@ -878,12 +877,12 @@ const resp = await qn.admin.getEndpointLogs("ep-123", { ```ruby # Ruby -resp = JSON.parse(qn.admin.get_endpoint_logs( +resp = qn.admin.get_endpoint_logs( id: "ep-123", from_time: "2026-04-01T00:00:00Z", to_time: "2026-04-02T00:00:00Z", limit: 100 -)) +) ``` ##### `get_log_details` / `getLogDetails` @@ -911,7 +910,7 @@ const resp = await qn.admin.getLogDetails("ep-123", "req-abc"); ```ruby # Ruby -resp = JSON.parse(qn.admin.get_log_details(id: "ep-123", request_id: "req-abc")) +resp = qn.admin.get_log_details(id: "ep-123", request_id: "req-abc") ``` #### Endpoint Security @@ -941,7 +940,7 @@ const resp = await qn.admin.getEndpointSecurity("ep-123"); ```ruby # Ruby -resp = JSON.parse(qn.admin.get_endpoint_security(id: "ep-123")) +resp = qn.admin.get_endpoint_security(id: "ep-123") ``` #### Security Options @@ -971,7 +970,7 @@ const resp = await qn.admin.getSecurityOptions("ep-123"); ```ruby # Ruby -resp = JSON.parse(qn.admin.get_security_options(id: "ep-123")) +resp = qn.admin.get_security_options(id: "ep-123") ``` ##### `update_security_options` / `updateSecurityOptions` @@ -1186,7 +1185,7 @@ await qn.admin.deleteIp("ep-123", "ip-1"); ```ruby # Ruby -resp = JSON.parse(qn.admin.delete_ip(id: "ep-123", ip_id: "ip-1")) +resp = qn.admin.delete_ip(id: "ep-123", ip_id: "ip-1") ``` #### Domain Masks @@ -1362,10 +1361,10 @@ const resp = await qn.admin.createRequestFilter("ep-123", { ```ruby # Ruby -resp = JSON.parse(qn.admin.create_request_filter( +resp = qn.admin.create_request_filter( id: "ep-123", methods: ["eth_blockNumber", "eth_getBalance"] -)) +) ``` ##### `update_request_filter` / `updateRequestFilter` @@ -1511,10 +1510,10 @@ await qn.admin.createOrUpdateIpCustomHeader("ep-123", { headerName: "X-Forwarded ```ruby # Ruby -JSON.parse(qn.admin.create_or_update_ip_custom_header( +qn.admin.create_or_update_ip_custom_header( id: "ep-123", header_name: "X-Forwarded-For" -)) +) ``` ##### `delete_ip_custom_header` / `deleteIpCustomHeader` @@ -1542,7 +1541,7 @@ await qn.admin.deleteIpCustomHeader("ep-123"); ```ruby # Ruby -JSON.parse(qn.admin.delete_ip_custom_header(id: "ep-123")) +qn.admin.delete_ip_custom_header(id: "ep-123") ``` #### Method Rate Limits @@ -1572,7 +1571,7 @@ const resp = await qn.admin.getMethodRateLimits("ep-123"); ```ruby # Ruby -resp = JSON.parse(qn.admin.get_method_rate_limits(id: "ep-123")) +resp = qn.admin.get_method_rate_limits(id: "ep-123") ``` ##### `create_method_rate_limit` / `createMethodRateLimit` @@ -1614,12 +1613,12 @@ const resp = await qn.admin.createMethodRateLimit("ep-123", { ```ruby # Ruby -resp = JSON.parse(qn.admin.create_method_rate_limit( +resp = qn.admin.create_method_rate_limit( id: "ep-123", interval: "second", methods: ["eth_call"], rate: 10 -)) +) ``` ##### `update_method_rate_limit` / `updateMethodRateLimit` @@ -1648,7 +1647,7 @@ await qn.admin.updateMethodRateLimit("ep-123", "rl-1", { rate: 50 }); ```ruby # Ruby -JSON.parse(qn.admin.update_method_rate_limit(id: "ep-123", method_rate_limit_id: "rl-1", rate: 50)) +qn.admin.update_method_rate_limit(id: "ep-123", method_rate_limit_id: "rl-1", rate: 50) ``` ##### `delete_method_rate_limit` / `deleteMethodRateLimit` @@ -1749,11 +1748,11 @@ const resp = await qn.admin.getEndpointMetrics("ep-123", { ```ruby # Ruby -resp = JSON.parse(qn.admin.get_endpoint_metrics( +resp = qn.admin.get_endpoint_metrics( id: "ep-123", period: "day", metric: "method_calls_over_time" -)) +) ``` ##### `get_account_metrics` / `getAccountMetrics` @@ -1789,7 +1788,7 @@ const resp = await qn.admin.getAccountMetrics({ ```ruby # Ruby -resp = JSON.parse(qn.admin.get_account_metrics(period: "day", metric: "credits_over_time")) +resp = qn.admin.get_account_metrics(period: "day", metric: "credits_over_time") ``` #### Chains @@ -1819,7 +1818,7 @@ const resp = await qn.admin.listChains(); ```ruby # Ruby -resp = JSON.parse(qn.admin.list_chains) +resp = qn.admin.list_chains ``` #### Billing @@ -1849,7 +1848,7 @@ const resp = await qn.admin.listInvoices(); ```ruby # Ruby -resp = JSON.parse(qn.admin.list_invoices) +resp = qn.admin.list_invoices ``` ##### `list_payments` / `listPayments` @@ -1877,7 +1876,7 @@ const resp = await qn.admin.listPayments(); ```ruby # Ruby -resp = JSON.parse(qn.admin.list_payments) +resp = qn.admin.list_payments ``` #### Bulk Operations @@ -1914,7 +1913,7 @@ const resp = await qn.admin.bulkUpdateEndpointStatus({ ```ruby # Ruby -resp = JSON.parse(qn.admin.bulk_update_endpoint_status(ids: ["ep-1", "ep-2"], status: "paused")) +resp = qn.admin.bulk_update_endpoint_status(ids: ["ep-1", "ep-2"], status: "paused") ``` ##### `bulk_add_tag` / `bulkAddTag` @@ -1946,7 +1945,7 @@ const resp = await qn.admin.bulkAddTag({ ids: ["ep-1", "ep-2"], label: "prod" }) ```ruby # Ruby -resp = JSON.parse(qn.admin.bulk_add_tag(ids: ["ep-1", "ep-2"], label: "prod")) +resp = qn.admin.bulk_add_tag(ids: ["ep-1", "ep-2"], label: "prod") ``` ##### `bulk_remove_tag` / `bulkRemoveTag` @@ -1978,7 +1977,7 @@ const resp = await qn.admin.bulkRemoveTag({ ids: ["ep-1", "ep-2"], tagId: 42 }); ```ruby # Ruby -resp = JSON.parse(qn.admin.bulk_remove_tag(ids: ["ep-1", "ep-2"], tag_id: 42)) +resp = qn.admin.bulk_remove_tag(ids: ["ep-1", "ep-2"], tag_id: 42) ``` #### Account Tags @@ -2008,7 +2007,7 @@ const resp = await qn.admin.listTags(); ```ruby # Ruby -resp = JSON.parse(qn.admin.list_tags) +resp = qn.admin.list_tags ``` ##### `rename_tag` / `renameTag` @@ -2037,7 +2036,7 @@ const resp = await qn.admin.renameTag(42, { label: "staging" }); ```ruby # Ruby -resp = JSON.parse(qn.admin.rename_tag(tag_id: 42, label: "staging")) +resp = qn.admin.rename_tag(tag_id: 42, label: "staging") ``` ##### `delete_account_tag` / `deleteAccountTag` @@ -2065,7 +2064,7 @@ await qn.admin.deleteAccountTag(42); ```ruby # Ruby -JSON.parse(qn.admin.delete_account_tag(id: 42)) +qn.admin.delete_account_tag(id: 42) ``` --- @@ -2202,7 +2201,7 @@ dest = QuicknodeSdk::DestinationAttributes.webhook( post_timeout_sec: 10, compression: "none" ) -stream = JSON.parse(qn.streams.create_stream( +stream = qn.streams.create_stream( name: "My Stream", network: "ethereum-mainnet", dataset: "block", @@ -2213,7 +2212,7 @@ stream = JSON.parse(qn.streams.create_stream( plan: "growth_plan", threshold_fetch_buffer: 1000, status: "active" -)) +) ``` ##### `list_streams` / `listStreams` @@ -2241,7 +2240,7 @@ const resp = await qn.streams.listStreams(); ```ruby # Ruby -resp = JSON.parse(qn.streams.list_streams({})) +resp = qn.streams.list_streams({}) ``` ##### `get_stream` / `getStream` @@ -2269,7 +2268,7 @@ const stream = await qn.streams.getStream("stream-id"); ```ruby # Ruby -stream = JSON.parse(qn.streams.get_stream(id: "stream-id")) +stream = qn.streams.get_stream(id: "stream-id") ``` ##### `update_stream` / `updateStream` @@ -2301,7 +2300,7 @@ const stream = await qn.streams.updateStream("stream-id", { name: "Renamed" }); ```ruby # Ruby -stream = JSON.parse(qn.streams.update_stream(id: "stream-id", name: "Renamed")) +stream = qn.streams.update_stream(id: "stream-id", name: "Renamed") ``` ##### `delete_stream` / `deleteStream` @@ -2459,11 +2458,11 @@ const resp = await qn.streams.testFilter({ ```ruby # Ruby -resp = JSON.parse(qn.streams.test_filter( +resp = qn.streams.test_filter( network: "ethereum-mainnet", dataset: "block", block: "17811625" -)) +) ``` ##### `get_enabled_count` / `getEnabledCount` @@ -2491,7 +2490,7 @@ const resp = await qn.streams.getEnabledCount(); ```ruby # Ruby -resp = JSON.parse(qn.streams.get_enabled_count({})) +resp = qn.streams.get_enabled_count({}) ``` --- @@ -2561,7 +2560,7 @@ const resp = await qn.webhooks.listWebhooks(); ```ruby # Ruby -resp = JSON.parse(qn.webhooks.list_webhooks({})) +resp = qn.webhooks.list_webhooks({}) ``` ##### `get_webhook` / `getWebhook` @@ -2589,7 +2588,7 @@ const webhook = await qn.webhooks.getWebhook("wh-1"); ```ruby # Ruby -webhook = JSON.parse(qn.webhooks.get_webhook(id: "wh-1")) +webhook = qn.webhooks.get_webhook(id: "wh-1") ``` ##### `create_webhook_from_template` / `createWebhookFromTemplate` @@ -2657,12 +2656,12 @@ template_args = JSON.generate({ templateId: "evmWalletFilter", templateArgs: { wallets: ["0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"] } }) -webhook = JSON.parse(qn.webhooks.create_webhook_from_template( +webhook = qn.webhooks.create_webhook_from_template( name: "Wallet Webhook", network: "ethereum-mainnet", destination_attributes_json: destination_attributes, template_args_json: template_args -)) +) ``` ##### `update_webhook` / `updateWebhook` @@ -2694,7 +2693,7 @@ const webhook = await qn.webhooks.updateWebhook("wh-1", { name: "Renamed Webhook ```ruby # Ruby -webhook = JSON.parse(qn.webhooks.update_webhook(id: "wh-1", name: "Renamed Webhook")) +webhook = qn.webhooks.update_webhook(id: "wh-1", name: "Renamed Webhook") ``` ##### `update_webhook_template` / `updateWebhookTemplate` @@ -2742,10 +2741,10 @@ template_args = JSON.generate({ templateId: "evmWalletFilter", templateArgs: { wallets: ["0xnewwallet"] } }) -webhook = JSON.parse(qn.webhooks.update_webhook_template( +webhook = qn.webhooks.update_webhook_template( webhook_id: "wh-1", template_args_json: template_args -)) +) ``` ##### `delete_webhook` / `deleteWebhook` @@ -2888,7 +2887,7 @@ const resp = await qn.webhooks.getEnabledCount(); ```ruby # Ruby -resp = JSON.parse(qn.webhooks.get_enabled_count) +resp = qn.webhooks.get_enabled_count ``` --- @@ -2955,7 +2954,7 @@ const resp = await qn.kvstore.getSets(); ```ruby # Ruby -resp = JSON.parse(qn.kvstore.get_sets({})) +resp = qn.kvstore.get_sets({}) ``` ##### `get_set` / `getSet` @@ -2983,7 +2982,7 @@ const resp = await qn.kvstore.getSet("my-key"); ```ruby # Ruby -resp = JSON.parse(qn.kvstore.get_set(key: "my-key")) +resp = qn.kvstore.get_set(key: "my-key") ``` ##### `bulk_sets` / `bulkSets` @@ -3113,7 +3112,7 @@ const resp = await qn.kvstore.getLists(); ```ruby # Ruby -resp = JSON.parse(qn.kvstore.get_lists({})) +resp = qn.kvstore.get_lists({}) ``` ##### `get_list` / `getList` @@ -3141,7 +3140,7 @@ const resp = await qn.kvstore.getList("my-list"); ```ruby # Ruby -resp = JSON.parse(qn.kvstore.get_list(key: "my-list")) +resp = qn.kvstore.get_list(key: "my-list") ``` ##### `update_list` / `updateList` @@ -3241,7 +3240,7 @@ const resp = await qn.kvstore.listContainsItem("my-list", "0x123"); ```ruby # Ruby -resp = JSON.parse(qn.kvstore.list_contains_item(key: "my-list", item: "0x123")) +resp = qn.kvstore.list_contains_item(key: "my-list", item: "0x123") ``` ##### `delete_list_item` / `deleteListItem` From 87d5577ea35590dadb69b0547436747a18407a62 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 27 Apr 2026 12:10:24 -0400 Subject: [PATCH 13/18] docs: update CLAUDE.md Ruby binding section for Hashie::Mash returns --- CLAUDE.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b378855..d3c813e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,13 +139,14 @@ When adding a new `SdkError` variant: `crates/node/src/lib.rs` uses `#[napi(constructor)]` and `#[napi(getter)]` macros. napi handles async conversion automatically. ### Ruby Binding Pattern -`crates/ruby/src/lib.rs` uses the `magnus` crate. All async SDK calls are wrapped via a single shared `tokio::runtime` (static `OnceLock`) using `.block_on()` to produce a synchronous Ruby API. Methods returning data return **JSON strings** — callers must parse with `JSON.parse()`. All parameters are passed as a single Ruby Hash with symbol keys (e.g. `get_endpoints(limit: 20)`). Required keys throw `ArgumentError` if missing; unknown keys also throw `ArgumentError` (validated via `validate_keys`). The magnus arity limit of 15 is why this pattern is used uniformly — all methods are registered with arity `1` (or `0` for zero-param methods). Classes are exposed under the `QuicknodeSdk` module and registered via `#[magnus::init(name = "quicknode_sdk")]`. +`crates/ruby/src/lib.rs` uses the `magnus` crate. All async SDK calls are wrapped via a single shared `tokio::runtime` (static `OnceLock`) using `.block_on()` to produce a synchronous Ruby API. Methods returning data return native Ruby `Hash`/`Array` (via `serde_magnus`); a per-client Ruby delegator in `ruby/lib/quicknode_sdk/clients/` wraps responses in `Hashie::Mash` so callers can use dot-notation and indifferent access. All parameters are passed as a single Ruby Hash with symbol keys (e.g. `get_endpoints(limit: 20)`). Required keys throw `ArgumentError` if missing; unknown keys also throw `ArgumentError` (validated via `validate_keys`). The magnus arity limit of 15 is why this pattern is used uniformly — all methods are registered with arity `1` (or `0` for zero-param methods). The native binding classes are registered under `QuicknodeSdk::Native::*` (via `#[magnus::init(name = "quicknode_sdk")]`); user-facing `QuicknodeSdk::SDK`/`Admin`/`Streams`/`Webhooks`/`KvStore` are pure-Ruby wrapper classes defined in `ruby/lib/quicknode_sdk/`. When adding a new Ruby method: 1. Accept `opts: RHash` as the single parameter 2. Call `validate_keys(&opts, &["key1", "key2", ...])?;` as the first line 3. Use `hash_require_string/i64/i32/bool/vec_string` for required fields and `hash_get_*` for optional fields -4. Register with `method!(ClassName::method_name, 1)` in the `init` function +4. Register with `method!(ClassName::method_name, 1)` in the `init` function on the `QuicknodeSdk::Native::*` class (the user-facing Ruby wrapper in `ruby/lib/quicknode_sdk/clients/` picks up new methods automatically via `method_missing`) +5. For methods returning data, the return type is `Result` and the call ends with `.and_then(to_ruby)`. The Ruby delegator wraps the result in `Hashie::Mash` automatically — no per-method code is needed on the Ruby side. ### Testing Core clients are tested using mocked API calls with wiremock. All functions making external http calls should be tested this way and test the happy path, errors, with params, and with bad params. Keep testing focused and flexible, avoid overtesting From 3c5ccc33bf7f1351fd8d3ca28b32d814e52635b1 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 27 Apr 2026 12:10:55 -0400 Subject: [PATCH 14/18] ruby: update inline comment to reflect Hashie::Mash return path --- crates/ruby/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ruby/src/lib.rs b/crates/ruby/src/lib.rs index 92740a3..8eacdd3 100644 --- a/crates/ruby/src/lib.rs +++ b/crates/ruby/src/lib.rs @@ -266,7 +266,8 @@ impl QuicknodeSdk { // ── AdminApiClient ────────────────────────────────────────────────────────── // -// All methods return JSON strings. Call JSON.parse on the result in Ruby. +// Methods returning data return native Ruby Hash/Array via serde_magnus. The +// Ruby package wraps these in Hashie::Mash before returning to the user. #[magnus::wrap(class = "QuicknodeSdk::Native::Admin", free_immediately, size)] #[derive(Clone)] From 9bb933a0a08061da70352f156056af0224ad89a6 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 27 Apr 2026 12:27:14 -0400 Subject: [PATCH 15/18] ruby: switch from Hashie::Mash to IndifferentHash to avoid method collisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hashie::Mash exposes fields as methods, which collides with built-ins like Hash#chain (Enumerable) and Hash#methods (Kernel) — emitting noisy warnings on every API field with that name and silently returning the wrong value under dot-notation. Replaced with a Hash subclass using Hashie::Extensions::IndifferentAccess. Symbol- and string-key access still work; dot-notation is gone. Examples and docs updated to use symbol keys as the primary access style. --- CLAUDE.md | 4 +- README.md | 4 +- ruby/examples/admin.rb | 20 +++--- ruby/examples/admin_e2e.rb | 116 ++++++++++++++++----------------- ruby/examples/kvstore_e2e.rb | 11 ++-- ruby/examples/streams.rb | 2 +- ruby/examples/streams_e2e.rb | 16 ++--- ruby/examples/webhooks_e2e.rb | 14 ++-- ruby/lib/quicknode_sdk/wrap.rb | 7 +- 9 files changed, 99 insertions(+), 95 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d3c813e..65c6e52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,14 +139,14 @@ When adding a new `SdkError` variant: `crates/node/src/lib.rs` uses `#[napi(constructor)]` and `#[napi(getter)]` macros. napi handles async conversion automatically. ### Ruby Binding Pattern -`crates/ruby/src/lib.rs` uses the `magnus` crate. All async SDK calls are wrapped via a single shared `tokio::runtime` (static `OnceLock`) using `.block_on()` to produce a synchronous Ruby API. Methods returning data return native Ruby `Hash`/`Array` (via `serde_magnus`); a per-client Ruby delegator in `ruby/lib/quicknode_sdk/clients/` wraps responses in `Hashie::Mash` so callers can use dot-notation and indifferent access. All parameters are passed as a single Ruby Hash with symbol keys (e.g. `get_endpoints(limit: 20)`). Required keys throw `ArgumentError` if missing; unknown keys also throw `ArgumentError` (validated via `validate_keys`). The magnus arity limit of 15 is why this pattern is used uniformly — all methods are registered with arity `1` (or `0` for zero-param methods). The native binding classes are registered under `QuicknodeSdk::Native::*` (via `#[magnus::init(name = "quicknode_sdk")]`); user-facing `QuicknodeSdk::SDK`/`Admin`/`Streams`/`Webhooks`/`KvStore` are pure-Ruby wrapper classes defined in `ruby/lib/quicknode_sdk/`. +`crates/ruby/src/lib.rs` uses the `magnus` crate. All async SDK calls are wrapped via a single shared `tokio::runtime` (static `OnceLock`) using `.block_on()` to produce a synchronous Ruby API. Methods returning data return native Ruby `Hash`/`Array` (via `serde_magnus`); a per-client Ruby delegator in `ruby/lib/quicknode_sdk/clients/` wraps responses in `QuicknodeSdk::IndifferentHash` (a `Hash` subclass with `Hashie::Extensions::IndifferentAccess`) so callers can use either symbol or string keys. All parameters are passed as a single Ruby Hash with symbol keys (e.g. `get_endpoints(limit: 20)`). Required keys throw `ArgumentError` if missing; unknown keys also throw `ArgumentError` (validated via `validate_keys`). The magnus arity limit of 15 is why this pattern is used uniformly — all methods are registered with arity `1` (or `0` for zero-param methods). The native binding classes are registered under `QuicknodeSdk::Native::*` (via `#[magnus::init(name = "quicknode_sdk")]`); user-facing `QuicknodeSdk::SDK`/`Admin`/`Streams`/`Webhooks`/`KvStore` are pure-Ruby wrapper classes defined in `ruby/lib/quicknode_sdk/`. When adding a new Ruby method: 1. Accept `opts: RHash` as the single parameter 2. Call `validate_keys(&opts, &["key1", "key2", ...])?;` as the first line 3. Use `hash_require_string/i64/i32/bool/vec_string` for required fields and `hash_get_*` for optional fields 4. Register with `method!(ClassName::method_name, 1)` in the `init` function on the `QuicknodeSdk::Native::*` class (the user-facing Ruby wrapper in `ruby/lib/quicknode_sdk/clients/` picks up new methods automatically via `method_missing`) -5. For methods returning data, the return type is `Result` and the call ends with `.and_then(to_ruby)`. The Ruby delegator wraps the result in `Hashie::Mash` automatically — no per-method code is needed on the Ruby side. +5. For methods returning data, the return type is `Result` and the call ends with `.and_then(to_ruby)`. The Ruby delegator wraps the result in `QuicknodeSdk::IndifferentHash` automatically — no per-method code is needed on the Ruby side. ### Testing Core clients are tested using mocked API calls with wiremock. All functions making external http calls should be tested this way and test the happy path, errors, with params, and with bad params. Keep testing focused and flexible, avoid overtesting diff --git a/README.md b/README.md index 9ccd505..3c07811 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ require "quicknode_sdk" qn = QuicknodeSdk::SDK.from_env resp = qn.admin.get_endpoints -puts "#{resp.data.length} endpoints" +puts "#{resp[:data].length} endpoints" ``` ## Configuration @@ -186,7 +186,7 @@ Each method below shows the call pattern in Rust, Python, Node.js, and Ruby in t - **Rust**: methods are `async` and return `Result`. Request structs use the [`bon`](https://docs.rs/bon) builder pattern via `::builder()`. - **Python**: methods are `async` — call with `await`. Parameters are kwargs; responses are native `pyclass` objects with attribute access. - **Node.js**: methods are `async` and take a single options object with camelCase keys. -- **Ruby**: methods are **blocking** (not async). Parameters are a single Hash with symbol keys. Responses that carry data are returned as `Hashie::Mash` — access fields via dot-notation (`resp.data.first.id`), symbol keys (`resp[:data]`), or string keys (`resp["data"]`). Unknown keys raise `ArgumentError`. +- **Ruby**: methods are **blocking** (not async). Parameters are a single Hash with symbol keys. Responses that carry data are returned as a `Hash` with indifferent access — `resp[:data]` and `resp["data"]` both work. Unknown parameter keys raise `ArgumentError`. --- diff --git a/ruby/examples/admin.rb b/ruby/examples/admin.rb index 1f59e57..de94878 100644 --- a/ruby/examples/admin.rb +++ b/ruby/examples/admin.rb @@ -8,21 +8,21 @@ sort_direction: "desc" ) -if response.pagination - p = response.pagination - puts "#{response.data.length} of #{p.total} (offset #{p.offset}, limit #{p.limit})" +if response[:pagination] + p = response[:pagination] + puts "#{response[:data].length} of #{p[:total]} (offset #{p[:offset]}, limit #{p[:limit]})" end -response.data.each do |ep| - puts "#{ep.id} | #{ep.name} | #{ep.status} | #{ep.network} | " \ - "dedicated=#{ep.is_dedicated} flat=#{ep.is_flat_rate}" +response[:data].each do |ep| + puts "#{ep[:id]} | #{ep[:name]} | #{ep[:status]} | #{ep[:network]} | " \ + "dedicated=#{ep[:is_dedicated]} flat=#{ep[:is_flat_rate]}" end tags = qn.admin.list_tags -puts "account tags: #{tags.data&.tags&.length || 0}" +puts "account tags: #{tags.dig(:data, :tags)&.length || 0}" -first = response.data.first +first = response[:data].first if first - sec = qn.admin.get_endpoint_security(id: first.id) - puts "get_endpoint_security: has_data=#{!sec.data.nil?}" + sec = qn.admin.get_endpoint_security(id: first[:id]) + puts "get_endpoint_security: has_data=#{!sec[:data].nil?}" end diff --git a/ruby/examples/admin_e2e.rb b/ruby/examples/admin_e2e.rb index 33803e3..566eafa 100644 --- a/ruby/examples/admin_e2e.rb +++ b/ruby/examples/admin_e2e.rb @@ -6,67 +6,67 @@ # ── Read-only globals ───────────────────────────────────────────────────────── resp = qn.admin.list_chains -puts "list_chains: #{resp.data.length} chains" +puts "list_chains: #{resp[:data].length} chains" resp = qn.admin.get_endpoints(limit: 5) -puts "get_endpoints: #{resp.data.length} endpoints" +puts "get_endpoints: #{resp[:data].length} endpoints" -# Verify all three access styles work on a sample response. +# Verify symbol- and string-key access work interchangeably. sample = qn.admin.get_endpoints(limit: 1) -raise "expected Hashie::Mash" unless sample.is_a?(Hashie::Mash) -raise "indifferent access broken" unless sample.data == sample[:data] && sample[:data] == sample["data"] +raise "expected QuicknodeSdk::IndifferentHash" unless sample.is_a?(QuicknodeSdk::IndifferentHash) +raise "indifferent access broken" unless sample[:data] == sample["data"] puts "indifferent access: ok" resp = qn.admin.get_usage({}) -puts "get_usage: #{resp.data.inspect}" +puts "get_usage: #{resp[:data].inspect}" sleep 0.5 resp = qn.admin.get_usage_by_endpoint({}) -puts "get_usage_by_endpoint: #{resp.data.inspect}" +puts "get_usage_by_endpoint: #{resp[:data].inspect}" resp = qn.admin.get_usage_by_method({}) -puts "get_usage_by_method: #{resp.data.inspect}" +puts "get_usage_by_method: #{resp[:data].inspect}" sleep 0.5 resp = qn.admin.get_usage_by_chain({}) -puts "get_usage_by_chain: #{resp.data.inspect}" +puts "get_usage_by_chain: #{resp[:data].inspect}" resp = qn.admin.get_usage_by_tag({}) -puts "get_usage_by_tag: #{resp.data.inspect}" +puts "get_usage_by_tag: #{resp[:data].inspect}" resp = qn.admin.list_tags -puts "list_tags: #{resp.data&.tags&.length || 0} tags" +puts "list_tags: #{resp.dig(:data, :tags)&.length || 0} tags" resp = qn.admin.get_account_metrics(period: "day", metric: "requests") -puts "get_account_metrics: #{resp.data.length} series" +puts "get_account_metrics: #{resp[:data].length} series" resp = qn.admin.list_invoices -puts "list_invoices: #{resp.data.inspect}" +puts "list_invoices: #{resp[:data].inspect}" resp = qn.admin.list_payments -puts "list_payments: #{resp.data.inspect}" +puts "list_payments: #{resp[:data].inspect}" resp = qn.admin.list_teams -puts "list_teams: #{resp.data.length} teams" +puts "list_teams: #{resp[:data].length} teams" sleep 0.5 # ── Create endpoint ─────────────────────────────────────────────────────────── resp = qn.admin.create_endpoint(chain: "ethereum", network: "mainnet") -endpoint_id = resp.data&.id +endpoint_id = resp.dig(:data, :id) unless endpoint_id - puts "create_endpoint failed: #{resp.error}" + puts "create_endpoint failed: #{resp[:error]}" exit 1 end -puts "create_endpoint: #{endpoint_id} (#{resp.data.http_url})" +puts "create_endpoint: #{endpoint_id} (#{resp.dig(:data, :http_url)})" # ── Endpoint CRUD ───────────────────────────────────────────────────────────── resp = qn.admin.show_endpoint(id: endpoint_id) -puts "show_endpoint: #{resp.data.id}" +puts "show_endpoint: #{resp.dig(:data, :id)}" qn.admin.update_endpoint(id: endpoint_id, label: "sdk-example") puts "update_endpoint: ok" @@ -74,10 +74,10 @@ sleep 0.5 resp = qn.admin.update_endpoint_status(id: endpoint_id, status: "paused") -puts "update_endpoint_status paused: #{resp.data}" +puts "update_endpoint_status paused: #{resp[:data]}" resp = qn.admin.update_endpoint_status(id: endpoint_id, status: "active") -puts "update_endpoint_status active: #{resp.data}" +puts "update_endpoint_status active: #{resp[:data]}" sleep 1 @@ -88,7 +88,7 @@ sleep 0.5 resp = qn.admin.show_endpoint(id: endpoint_id) -tag_id = resp.data&.tags&.first&.tag_id&.to_s +tag_id = resp.dig(:data, :tags, 0, :tag_id)&.to_s if tag_id qn.admin.delete_tag(id: endpoint_id, tag_id: tag_id) puts "delete_tag: ok" @@ -101,25 +101,25 @@ from_time: "2025-01-01T00:00:00Z", to_time: "2025-01-02T00:00:00Z" ) -puts "get_endpoint_logs: #{resp.data.length} entries" +puts "get_endpoint_logs: #{resp[:data].length} entries" sleep 1 resp = qn.admin.get_endpoint_metrics(id: endpoint_id, period: "day", metric: "credits_over_time") -puts "get_endpoint_metrics: #{resp.data.length} series" +puts "get_endpoint_metrics: #{resp[:data].length} series" # ── Security options ────────────────────────────────────────────────────────── resp = qn.admin.get_security_options(id: endpoint_id) -puts "get_security_options: #{resp.data.length} options" +puts "get_security_options: #{resp[:data].length} options" sleep 1 resp = qn.admin.get_endpoint_security(id: endpoint_id) -puts "get_endpoint_security: has_data=#{!resp.data.nil?}" +puts "get_endpoint_security: has_data=#{!resp[:data].nil?}" resp = qn.admin.update_security_options(id: endpoint_id, tokens: "enabled") -puts "update_security_options: #{resp.data.length} options" +puts "update_security_options: #{resp[:data].length} options" sleep 0.5 @@ -130,10 +130,10 @@ puts "create_token: ok" resp = qn.admin.show_endpoint(id: endpoint_id) -token_id = resp.data&.security&.tokens&.first&.id +token_id = resp.dig(:data, :security, :tokens, 0, :id) if token_id resp = qn.admin.delete_token(id: endpoint_id, token_id: token_id) - puts "delete_token: #{resp.data}" + puts "delete_token: #{resp[:data]}" end # ── Referrer ────────────────────────────────────────────────────────────────── @@ -143,10 +143,10 @@ sleep 0.5 resp = qn.admin.show_endpoint(id: endpoint_id) -referrer_id = resp.data&.security&.referrers&.first&.id +referrer_id = resp.dig(:data, :security, :referrers, 0, :id) if referrer_id resp = qn.admin.delete_referrer(id: endpoint_id, referrer_id: referrer_id) - puts "delete_referrer: #{resp.data}" + puts "delete_referrer: #{resp[:data]}" end # ── IP allowlist ────────────────────────────────────────────────────────────── @@ -155,12 +155,12 @@ puts "create_ip: ok" resp = qn.admin.show_endpoint(id: endpoint_id) -ip_id = resp.data&.security&.ips&.first&.id +ip_id = resp.dig(:data, :security, :ips, 0, :id) sleep 0.5 if ip_id resp = qn.admin.delete_ip(id: endpoint_id, ip_id: ip_id) - puts "delete_ip: #{resp.data}" + puts "delete_ip: #{resp[:data]}" end # ── Domain mask ─────────────────────────────────────────────────────────────── @@ -169,10 +169,10 @@ puts "create_domain_mask: ok" resp = qn.admin.show_endpoint(id: endpoint_id) -mask_id = resp.data&.security&.domain_masks&.first&.id +mask_id = resp.dig(:data, :security, :domain_masks, 0, :id) if mask_id resp = qn.admin.delete_domain_mask(id: endpoint_id, domain_mask_id: mask_id) - puts "delete_domain_mask: #{resp.data}" + puts "delete_domain_mask: #{resp[:data]}" end # ── JWT (placeholder key will fail) ────────────────────────────────────────── @@ -190,7 +190,7 @@ end resp = qn.admin.show_endpoint(id: endpoint_id) -jwt_id = resp.data&.security&.jwts&.first&.id +jwt_id = resp.dig(:data, :security, :jwts, 0, :id) if jwt_id qn.admin.delete_jwt(id: endpoint_id, jwt_id: jwt_id) puts "delete_jwt: ok" @@ -199,8 +199,8 @@ # ── Request filter ──────────────────────────────────────────────────────────── resp = qn.admin.create_request_filter(id: endpoint_id, methods: ["eth_getBalance"]) -rf_id = resp.data&.id -puts "create_request_filter: #{resp.data}" +rf_id = resp.dig(:data, :id) +puts "create_request_filter: #{resp[:data]}" if rf_id qn.admin.update_request_filter(id: endpoint_id, request_filter_id: rf_id, methods: ["eth_call"]) @@ -213,10 +213,10 @@ # ── IP custom header ────────────────────────────────────────────────────────── resp = qn.admin.create_or_update_ip_custom_header(id: endpoint_id, header_name: "X-Custom-Header") -puts "create_or_update_ip_custom_header: #{resp.data}" +puts "create_or_update_ip_custom_header: #{resp[:data]}" resp = qn.admin.delete_ip_custom_header(id: endpoint_id) -puts "delete_ip_custom_header: #{resp.data}" +puts "delete_ip_custom_header: #{resp[:data]}" sleep 0.5 @@ -226,15 +226,15 @@ #puts "update_rate_limits: ok" resp = qn.admin.get_method_rate_limits(id: endpoint_id) -puts "get_method_rate_limits: #{resp.data}" +puts "get_method_rate_limits: #{resp[:data]}" resp = qn.admin.create_method_rate_limit(id: endpoint_id, interval: "second", methods: ["eth_call"], rate: 5) -mrl_id = resp.data&.id -puts "create_method_rate_limit: #{resp.data}" +mrl_id = resp.dig(:data, :id) +puts "create_method_rate_limit: #{resp[:data]}" if mrl_id resp = qn.admin.update_method_rate_limit(id: endpoint_id, method_rate_limit_id: mrl_id, rate: 10) - puts "update_method_rate_limit: #{resp.data}" + puts "update_method_rate_limit: #{resp[:data]}" qn.admin.delete_method_rate_limit(id: endpoint_id, method_rate_limit_id: mrl_id) puts "delete_method_rate_limit: ok" @@ -255,29 +255,29 @@ # ── Bulk endpoint ops (single-endpoint batch) ──────────────────────────────── resp = qn.admin.bulk_update_endpoint_status(ids: [endpoint_id], status: "paused") -puts "bulk_update_endpoint_status paused: #{resp.data}" +puts "bulk_update_endpoint_status paused: #{resp[:data]}" resp = qn.admin.bulk_update_endpoint_status(ids: [endpoint_id], status: "active") -puts "bulk_update_endpoint_status active: #{resp.data}" +puts "bulk_update_endpoint_status active: #{resp[:data]}" # ── Account-level tags (bulk_add/remove + rename/delete) ───────────────────── tag_suffix = SecureRandom.hex(4) resp = qn.admin.bulk_add_tag(ids: [endpoint_id], label: "sdk-bulk-#{tag_suffix}") -puts "bulk_add_tag: #{resp.data}" -bulk_tag_id = resp.data&.tag&.tag_id +puts "bulk_add_tag: #{resp[:data]}" +bulk_tag_id = resp.dig(:data, :tag, :tag_id) sleep 0.5 if bulk_tag_id resp = qn.admin.rename_tag(id: bulk_tag_id, label: "sdk-renamed-#{tag_suffix}") - puts "rename_tag: #{resp.data}" + puts "rename_tag: #{resp[:data]}" resp = qn.admin.bulk_remove_tag(ids: [endpoint_id], tag_id: bulk_tag_id) - puts "bulk_remove_tag: #{resp.data}" + puts "bulk_remove_tag: #{resp[:data]}" resp = qn.admin.delete_account_tag(id: bulk_tag_id) - puts "delete_account_tag: #{resp.data}" + puts "delete_account_tag: #{resp[:data]}" end sleep 0.5 @@ -285,27 +285,27 @@ # ── Teams ───────────────────────────────────────────────────────────────────── resp = qn.admin.create_team(name: "sdk-example-team") -team_id = resp.data&.id -puts "create_team: #{resp.data}" +team_id = resp.dig(:data, :id) +puts "create_team: #{resp[:data]}" sleep 0.5 if team_id resp = qn.admin.get_team(id: team_id) - puts "get_team: #{resp.data.name}" + puts "get_team: #{resp.dig(:data, :name)}" resp = qn.admin.list_team_endpoints(id: team_id) - puts "list_team_endpoints: #{resp.data.length} endpoints" + puts "list_team_endpoints: #{resp[:data].length} endpoints" sleep 0.5 resp = qn.admin.update_team_endpoints(id: team_id, endpoint_ids: [endpoint_id]) - puts "update_team_endpoints: #{resp.data}" + puts "update_team_endpoints: #{resp[:data]}" sleep 0.5 begin resp = qn.admin.invite_team_member(id: team_id, email: "placeholder@example.com") - puts "invite_team_member: #{resp.data}" + puts "invite_team_member: #{resp[:data]}" rescue => e puts "invite_team_member error (expected with placeholder email): #{e}" end @@ -313,7 +313,7 @@ sleep 0.5 resp = qn.admin.delete_team(id: team_id) - puts "delete_team: #{resp.data}" + puts "delete_team: #{resp[:data]}" end sleep 0.5 diff --git a/ruby/examples/kvstore_e2e.rb b/ruby/examples/kvstore_e2e.rb index 0fe44e6..07a5167 100644 --- a/ruby/examples/kvstore_e2e.rb +++ b/ruby/examples/kvstore_e2e.rb @@ -8,10 +8,10 @@ puts "created set: e2e-set-key" set = qn.kvstore.get_set(key: "e2e-set-key") -puts "get set: #{set.value}" +puts "get set: #{set[:value]}" sets = qn.kvstore.get_sets({}) -puts "all sets: #{sets.data.map(&:key).inspect}" +puts "all sets: #{sets[:data].map { |e| e[:key] }.inspect}" qn.kvstore.bulk_sets(delete_sets: ["e2e-set-key"]) puts "bulk sets: deleted e2e-set-key" @@ -28,17 +28,16 @@ puts "created list: e2e-list-key" list = qn.kvstore.get_list(key: "e2e-list-key") -puts "get list items: #{list.data.items.inspect}" +puts "get list items: #{list.dig(:data, :items).inspect}" -# Note: data.keys would call Hash#keys (Mash subclasses Hash); use string access for the API field. lists = qn.kvstore.get_lists({}) -puts "all list keys: #{lists.data["keys"].inspect}" +puts "all list keys: #{lists.dig(:data, :keys).inspect}" qn.kvstore.add_list_item(key: "e2e-list-key", item: "0x123") puts "added list item: 0x123" contains = qn.kvstore.list_contains_item(key: "e2e-list-key", item: "0x123") -puts "list contains 0x123: #{contains.exists}" +puts "list contains 0x123: #{contains[:exists]}" qn.kvstore.update_list(key: "e2e-list-key", add_items: ["0x456"], remove_items: ["0xabc"]) puts "updated list: added 0x456, removed 0xabc" diff --git a/ruby/examples/streams.rb b/ruby/examples/streams.rb index 0170578..6c6e362 100644 --- a/ruby/examples/streams.rb +++ b/ruby/examples/streams.rb @@ -28,4 +28,4 @@ status: "active" ) -puts "#{stream.id} | #{stream.name} | #{stream.status}" +puts "#{stream[:id]} | #{stream[:name]} | #{stream[:status]}" diff --git a/ruby/examples/streams_e2e.rb b/ruby/examples/streams_e2e.rb index 6a249ee..bedfcae 100644 --- a/ruby/examples/streams_e2e.rb +++ b/ruby/examples/streams_e2e.rb @@ -3,10 +3,10 @@ qn = QuicknodeSdk::SDK.from_env before = qn.streams.list_streams({}) -puts "streams before: #{before.pageInfo.total}" +puts "streams before: #{before.dig(:pageInfo, :total)}" count = qn.streams.get_enabled_count({}) -puts "enabled count: #{count.total}" +puts "enabled count: #{count[:total]}" filter_result = qn.streams.test_filter( network: "ethereum-mainnet", @@ -14,7 +14,7 @@ block: "17811625", filter_function: "ZnVuY3Rpb24gbWFpbihkYXRhKSB7IHJldHVybiBkYXRhOyB9" ) -puts "filter logs: #{filter_result.logs}" +puts "filter logs: #{filter_result[:logs]}" sleep 1 dest = QuicknodeSdk::DestinationAttributes.webhook( @@ -51,14 +51,14 @@ elastic_batch_enabled: true, status: "active" ) -stream_id = stream.id -puts "created: #{stream_id} | #{stream.status}" +stream_id = stream[:id] +puts "created: #{stream_id} | #{stream[:status]}" fetched = qn.streams.get_stream(id: stream_id) -puts "fetched: #{fetched.id} | #{fetched.name}" +puts "fetched: #{fetched[:id]} | #{fetched[:name]}" updated = qn.streams.update_stream(id: stream_id, name: "E2E Test Stream Updated") -puts "updated name: #{updated.name}" +puts "updated name: #{updated[:name]}" sleep 1 qn.streams.pause_stream(id: stream_id) @@ -72,4 +72,4 @@ sleep 1 after = qn.streams.list_streams({}) -puts "streams after: #{after.pageInfo.total}" +puts "streams after: #{after.dig(:pageInfo, :total)}" diff --git a/ruby/examples/webhooks_e2e.rb b/ruby/examples/webhooks_e2e.rb index a398df8..61a747c 100644 --- a/ruby/examples/webhooks_e2e.rb +++ b/ruby/examples/webhooks_e2e.rb @@ -4,10 +4,10 @@ qn = QuicknodeSdk::SDK.from_env before = qn.webhooks.list_webhooks({}) -puts "webhooks before: #{before.data.length} (total=#{before.pageInfo.total})" +puts "webhooks before: #{before[:data].length} (total=#{before.dig(:pageInfo, :total)})" count = qn.webhooks.get_enabled_count -puts "enabled count: #{count.total}" +puts "enabled count: #{count[:total]}" destination_attributes = JSON.generate({ url: "https://webhook.site/ae19071a-2dcc-4035-9cdf-406dcb4719ef", @@ -25,14 +25,14 @@ destination_attributes_json: destination_attributes, template_args_json: template_args ) -id = webhook.id -puts "created: #{id} | #{webhook.status}" +id = webhook[:id] +puts "created: #{id} | #{webhook[:status]}" fetched = qn.webhooks.get_webhook(id: id) -puts "fetched: #{fetched.id} | #{fetched.name}" +puts "fetched: #{fetched[:id]} | #{fetched[:name]}" updated = qn.webhooks.update_webhook(id: id, name: "E2E Test Webhook Updated") -puts "updated name: #{updated.name}" +puts "updated name: #{updated[:name]}" qn.webhooks.pause_webhook(id: id) puts "paused" @@ -45,4 +45,4 @@ sleep 1 after = qn.webhooks.list_webhooks({}) -puts "webhooks after: #{after.data.length} (total=#{after.pageInfo.total})" +puts "webhooks after: #{after[:data].length} (total=#{after.dig(:pageInfo, :total)})" diff --git a/ruby/lib/quicknode_sdk/wrap.rb b/ruby/lib/quicknode_sdk/wrap.rb index d1d1862..9bf1cc3 100644 --- a/ruby/lib/quicknode_sdk/wrap.rb +++ b/ruby/lib/quicknode_sdk/wrap.rb @@ -1,9 +1,14 @@ require "hashie" module QuicknodeSdk + class IndifferentHash < Hash + include Hashie::Extensions::MergeInitializer + include Hashie::Extensions::IndifferentAccess + end + def self.wrap(v) case v - when Hash then Hashie::Mash.new(v) + when Hash then IndifferentHash.new(v).tap { |h| h.each { |k, val| h[k] = wrap(val) } } when Array then v.map { |x| wrap(x) } else v end From 69e3446b8191460af92c83dc1fd62ac394af082d Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 27 Apr 2026 12:27:59 -0400 Subject: [PATCH 16/18] ruby: fix stale Hashie::Mash reference in inline comment --- crates/ruby/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ruby/src/lib.rs b/crates/ruby/src/lib.rs index 8eacdd3..ccf7fa0 100644 --- a/crates/ruby/src/lib.rs +++ b/crates/ruby/src/lib.rs @@ -267,7 +267,8 @@ impl QuicknodeSdk { // ── AdminApiClient ────────────────────────────────────────────────────────── // // Methods returning data return native Ruby Hash/Array via serde_magnus. The -// Ruby package wraps these in Hashie::Mash before returning to the user. +// Ruby package wraps these in QuicknodeSdk::IndifferentHash (a Hash subclass +// with Hashie::Extensions::IndifferentAccess) before returning to the user. #[magnus::wrap(class = "QuicknodeSdk::Native::Admin", free_immediately, size)] #[derive(Clone)] From 652a6f56d268af6c39b0ac6cc9a73b5f131dac27 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 27 Apr 2026 13:16:47 -0400 Subject: [PATCH 17/18] RBS --- CLAUDE.md | 2 + ruby/.solargraph.yml | 8 ++ ruby/Steepfile | 3 + ruby/quicknode_sdk.gemspec | 2 +- ruby/sig/quicknode_sdk.rbs | 162 +++++++++++++++++++++++++++++++++++++ 5 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 ruby/.solargraph.yml create mode 100644 ruby/Steepfile create mode 100644 ruby/sig/quicknode_sdk.rbs diff --git a/CLAUDE.md b/CLAUDE.md index 65c6e52..7136c7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -147,6 +147,7 @@ When adding a new Ruby method: 3. Use `hash_require_string/i64/i32/bool/vec_string` for required fields and `hash_get_*` for optional fields 4. Register with `method!(ClassName::method_name, 1)` in the `init` function on the `QuicknodeSdk::Native::*` class (the user-facing Ruby wrapper in `ruby/lib/quicknode_sdk/clients/` picks up new methods automatically via `method_missing`) 5. For methods returning data, the return type is `Result` and the call ends with `.and_then(to_ruby)`. The Ruby delegator wraps the result in `QuicknodeSdk::IndifferentHash` automatically — no per-method code is needed on the Ruby side. +6. Add a corresponding RBS signature to `ruby/sig/quicknode_sdk.rbs` under the matching client class. Method name must match step 4; keyword args must match the `validate_keys` list from step 2 with types derived from the `hash_require_*` / `hash_get_*` accessors (`hash_require_string` → `String`, `hash_get_string` → optional `?key: String`, `hash_*_i32`/`i64` → `Integer`, `hash_*_bool` → `bool`, `hash_*_vec_string` → `Array[String]`, `hash_get_map_string_string` → `Hash[String, String]`). Use `untyped` as the return type for methods that return `Result` (response is wrapped as a `Hashie::Mash` at the Ruby boundary) and `void` for methods returning `Result<(), Error>`. If a new exception class is added, also add it to the error hierarchy section at the top of the same file. ### Testing Core clients are tested using mocked API calls with wiremock. All functions making external http calls should be tested this way and test the happy path, errors, with params, and with bad params. Keep testing focused and flexible, avoid overtesting @@ -164,6 +165,7 @@ Core clients are tested using mocked API calls with wiremock. All functions maki - When updating `sdk.js` wrapper methods, verify the argument types match the underlying napi-rs constructor/method signature (object vs primitive) - When adding a new export to `sdk.js`, also add it to the named exports in `npm/sdk.mjs` — ESM named exports cannot be spread dynamically and must be listed explicitly - `python/sdk/__init__.pyi` is overwritten by `just python-build` — edit `init_manual_override.pyi` instead +- `ruby/sig/quicknode_sdk.rbs` is **manually maintained** — it is NOT auto-generated. It provides RBS type signatures so editor LSPs (VSCode Ruby LSP, Solargraph, RubyMine, Steep) autocomplete method names and keyword argument keys for `QuicknodeSdk::Admin/Streams/Webhooks/KvStore/DestinationAttributes` and the exception classes. Every change to method registration in `crates/ruby/src/lib.rs` (new method, renamed key, new arg, removed arg, type change) must be mirrored here in the same PR. Responses are typed as `untyped` because they're wrapped in `Hashie::Mash` at the Ruby boundary — that's intentional, do not try to type response shapes. - Always update examples alongside the code changes ### Security diff --git a/ruby/.solargraph.yml b/ruby/.solargraph.yml new file mode 100644 index 0000000..690a5d0 --- /dev/null +++ b/ruby/.solargraph.yml @@ -0,0 +1,8 @@ +include: + - "lib/**/*.rb" +exclude: + - "lib/quicknode_sdk.bundle" + - "examples/**/*" +require_paths: + - lib + - sig diff --git a/ruby/Steepfile b/ruby/Steepfile new file mode 100644 index 0000000..0fb9485 --- /dev/null +++ b/ruby/Steepfile @@ -0,0 +1,3 @@ +target :lib do + signature "sig" +end diff --git a/ruby/quicknode_sdk.gemspec b/ruby/quicknode_sdk.gemspec index da54c1a..f27abc2 100644 --- a/ruby/quicknode_sdk.gemspec +++ b/ruby/quicknode_sdk.gemspec @@ -4,7 +4,7 @@ Gem::Specification.new do |s| s.summary = "Quicknode SDK for Ruby" s.authors = ["Quicknode"] s.license = "MIT" - s.files = Dir["lib/**/*.rb"] + ["lib/quicknode_sdk.bundle"] + s.files = Dir["lib/**/*.rb"] + Dir["sig/**/*.rbs"] + ["lib/quicknode_sdk.bundle"] s.required_ruby_version = ">= 3.0" s.add_runtime_dependency "hashie", "~> 5.0" end diff --git a/ruby/sig/quicknode_sdk.rbs b/ruby/sig/quicknode_sdk.rbs new file mode 100644 index 0000000..b562352 --- /dev/null +++ b/ruby/sig/quicknode_sdk.rbs @@ -0,0 +1,162 @@ +module QuicknodeSdk + def self.wrap: (untyped v) -> untyped + + class Error < StandardError + end + + class ConfigError < Error + end + + class HttpError < Error + end + + class TimeoutError < HttpError + end + + class ConnectionError < HttpError + end + + class ApiError < Error + attr_reader status: Integer + attr_reader body: String + end + + class DecodeError < Error + attr_reader body: String + end + + class SDK + def self.from_env: () -> SDK + def initialize: (untyped native) -> void + def admin: () -> Admin + def streams: () -> Streams + def webhooks: () -> Webhooks + def kvstore: () -> KvStore + end + + class DestinationAttributes + def self.webhook: (url: String, ?max_retry: Integer, ?retry_interval_sec: Integer, ?post_timeout_sec: Integer, ?security_token: String, compression: String) -> DestinationAttributes + def self.s3: (endpoint: String, access_key: String, secret_key: String, bucket: String, object_prefix: String, compression: String, file_type: String, ?max_retry: Integer, ?retry_interval_sec: Integer, ?use_ssl: bool) -> DestinationAttributes + def self.azure: (storage_account: String, sas_token: String, container: String, compression: String, file_type: String, ?max_retry: Integer, ?retry_interval_sec: Integer, ?blob_prefix: String) -> DestinationAttributes + def self.postgres: (host: String, ?port: Integer, database: String, username: String, password: String, table_name: String, sslmode: String, ?max_retry: Integer, ?retry_interval_sec: Integer) -> DestinationAttributes + def self.mysql: (host: String, ?port: Integer, database: String, username: String, password: String, table_name: String, ?max_retry: Integer, ?retry_interval_sec: Integer) -> DestinationAttributes + def self.mongo: (host: String, database: String, username: String, password: String, collection_name: String, ?max_retry: Integer, ?retry_interval_sec: Integer) -> DestinationAttributes + def self.clickhouse: (hosts: String, database: String, username: String, password: String, table_name: String, default_table_engine_opts: String, ?default_granularity: Integer, default_compression: String, default_index_type: String, ?max_retry: Integer, ?retry_interval_sec: Integer, ?disable_datetime_precision: bool, ?dont_support_rename_column: bool, ?dont_support_empty_default_value: bool, ?skip_initialize_with_version: bool) -> DestinationAttributes + def self.snowflake: (account: String, host: String, ?port: Integer, protocol: String, database: String, schema: String, warehouse: String, username: String, password: String, ?max_retry: Integer, ?retry_interval_sec: Integer, ?table_name: String) -> DestinationAttributes + def self.kafka: (bootstrap_servers: String, topic_name: String, compression_type: String, ?batch_size: Integer, ?linger_ms: Integer, ?max_request_size: Integer, ?timeout_sec: Integer, ?max_retry: Integer, ?retry_interval_sec: Integer, ?username: String, ?password: String, ?protocol: String, ?mechanisms: String) -> DestinationAttributes + def self.redis: (host: String, ?port: Integer, ?database: Integer, username: String, password: String, key_name: String, ?max_retry: Integer, ?retry_interval_sec: Integer, ?tls: bool) -> DestinationAttributes + end + + class Admin + def initialize: (untyped native) -> void + + def get_endpoints: (?limit: Integer, ?offset: Integer, ?search: String, ?sort_by: String, ?sort_direction: String, ?networks: Array[String], ?statuses: Array[String], ?labels: Array[String], ?dedicated: bool, ?is_flat_rate: bool, ?tag_ids: Array[Integer], ?tag_labels: Array[String]) -> untyped + def create_endpoint: (?chain: String, ?network: String) -> untyped + def show_endpoint: (id: String) -> untyped + def update_endpoint: (id: String, ?label: String) -> void + def archive_endpoint: (id: String) -> void + def update_endpoint_status: (id: String, status: String) -> untyped + def create_tag: (id: String, ?label: String) -> void + def delete_tag: (id: String, tag_id: String) -> void + def get_usage: (?start_time: Integer, ?end_time: Integer) -> untyped + def get_usage_by_endpoint: (?start_time: Integer, ?end_time: Integer) -> untyped + def get_usage_by_method: (?start_time: Integer, ?end_time: Integer) -> untyped + def get_usage_by_chain: (?start_time: Integer, ?end_time: Integer) -> untyped + def get_endpoint_logs: (id: String, from_time: String, to_time: String, ?include_details: bool, ?limit: Integer, ?next_at: String) -> untyped + def get_log_details: (id: String, request_id: String) -> untyped + def get_security_options: (id: String) -> untyped + def update_security_options: (id: String, ?tokens: String, ?referrers: String, ?jwts: String, ?ips: String, ?domain_masks: String, ?hsts: String, ?cors: String, ?request_filters: String, ?ip_custom_header: String) -> untyped + def create_token: (id: String) -> void + def delete_token: (id: String, token_id: String) -> untyped + def create_referrer: (id: String, ?referrer: String) -> void + def delete_referrer: (id: String, referrer_id: String) -> untyped + def create_ip: (id: String, ?ip: String) -> void + def delete_ip: (id: String, ip_id: String) -> untyped + def create_domain_mask: (id: String, ?domain_mask: String) -> void + def delete_domain_mask: (id: String, domain_mask_id: String) -> untyped + def create_jwt: (id: String, ?public_key: String, ?kid: String, ?name: String) -> void + def delete_jwt: (id: String, jwt_id: String) -> void + def create_request_filter: (id: String, ?methods: Array[String]) -> untyped + def update_request_filter: (id: String, request_filter_id: String, ?methods: Array[String]) -> void + def delete_request_filter: (id: String, request_filter_id: String) -> void + def enable_multichain: (id: String) -> void + def disable_multichain: (id: String) -> void + def create_or_update_ip_custom_header: (id: String, header_name: String) -> untyped + def delete_ip_custom_header: (id: String) -> untyped + def get_method_rate_limits: (id: String) -> untyped + def create_method_rate_limit: (id: String, interval: String, methods: Array[String], rate: Integer) -> untyped + def update_method_rate_limit: (id: String, method_rate_limit_id: String, ?methods: Array[String], ?status: String, ?rate: Integer) -> untyped + def delete_method_rate_limit: (id: String, method_rate_limit_id: String) -> void + def update_rate_limits: (id: String, ?rps: Integer, ?rpm: Integer, ?rpd: Integer) -> void + def get_endpoint_metrics: (id: String, period: String, metric: String) -> untyped + def get_account_metrics: (period: String, metric: String, ?percentile: String) -> untyped + def list_chains: () -> untyped + def list_invoices: () -> untyped + def list_payments: () -> untyped + def list_teams: () -> untyped + def create_team: (name: String) -> untyped + def get_team: (id: Integer) -> untyped + def delete_team: (id: Integer) -> untyped + def list_team_endpoints: (id: Integer) -> untyped + def update_team_endpoints: (id: Integer, endpoint_ids: Array[String]) -> untyped + def invite_team_member: (id: Integer, email: String, ?full_name: String, ?role: String) -> untyped + def remove_team_member: (id: Integer, user_id: Integer, ?destroy_user: bool) -> untyped + def resend_team_invite: (id: Integer, user_id: Integer) -> untyped + def bulk_update_endpoint_status: (ids: Array[String], status: String) -> untyped + def bulk_add_tag: (ids: Array[String], label: String) -> untyped + def bulk_remove_tag: (ids: Array[String], tag_id: Integer) -> untyped + def list_tags: () -> untyped + def rename_tag: (id: Integer, label: String) -> untyped + def delete_account_tag: (id: Integer) -> untyped + def get_usage_by_tag: (?start_time: Integer, ?end_time: Integer) -> untyped + def get_endpoint_security: (id: String) -> untyped + end + + class Streams + def initialize: (untyped native) -> void + + def create_stream: (name: String, network: String, dataset: String, region: String, start_range: Integer, end_range: Integer, destination_attributes: DestinationAttributes, plan: String, threshold_fetch_buffer: Integer, ?dataset_batch_size: Integer, ?max_batch_size: Integer, ?max_buffer_range_size: Integer, ?max_buffer_processing_workers: Integer, ?keep_distance_from_tip: Integer, ?filter_function: String, ?filter_language: String, ?include_stream_metadata: String, ?product_type: String, ?status: String, ?notification_email: String, ?charge_min_cap: Integer, ?fix_block_reorgs: Integer, ?elastic_batch_enabled: bool, ?extra_destinations: Array[DestinationAttributes]) -> untyped + def list_streams: (?stream_type: String, ?offset: Integer, ?limit: Integer, ?order_by: String, ?order_direction: String) -> untyped + def delete_all_streams: () -> void + def get_stream: (id: String) -> untyped + def update_stream: (id: String, ?name: String, ?network: String, ?dataset: String, ?region: String, ?start_range: Integer, ?end_range: Integer, ?destination_attributes: DestinationAttributes, ?plan: String, ?threshold_fetch_buffer: Integer, ?dataset_batch_size: Integer, ?max_batch_size: Integer, ?max_buffer_range_size: Integer, ?max_buffer_processing_workers: Integer, ?keep_distance_from_tip: Integer, ?filter_function: String, ?filter_language: String, ?include_stream_metadata: String, ?notification_email: String, ?charge_min_cap: Integer, ?fix_block_reorgs: Integer, ?elastic_batch_enabled: bool, ?status: String, ?memo: String, ?extra_destinations: Array[DestinationAttributes]) -> untyped + def delete_stream: (id: String) -> void + def activate_stream: (id: String) -> void + def pause_stream: (id: String) -> void + def test_filter: (network: String, dataset: String, block: String, ?filter_function: String, ?filter_language: String) -> untyped + def get_enabled_count: (?stream_type: String) -> untyped + end + + class Webhooks + def initialize: (untyped native) -> void + + def list_webhooks: (?limit: Integer, ?offset: Integer) -> untyped + def delete_all_webhooks: () -> void + def get_webhook: (id: String) -> untyped + def update_webhook: (id: String, ?name: String, ?notification_email: String, ?destination_attributes_json: String) -> untyped + def delete_webhook: (id: String) -> void + def pause_webhook: (id: String) -> void + def activate_webhook: (id: String, start_from: String) -> void + def get_enabled_count: () -> untyped + def create_webhook_from_template: (name: String, network: String, destination_attributes_json: String, template_args_json: String, ?notification_email: String) -> untyped + def update_webhook_template: (webhook_id: String, template_args_json: String, ?name: String, ?notification_email: String) -> untyped + end + + class KvStore + def initialize: (untyped native) -> void + + def create_set: (key: String, value: String) -> void + def get_sets: (?limit: Integer, ?cursor: String) -> untyped + def get_set: (key: String) -> untyped + def bulk_sets: (?add_sets: Hash[String, String], ?delete_sets: Array[String]) -> void + def delete_set: (key: String) -> void + def create_list: (key: String, items: Array[String]) -> void + def get_lists: (?limit: Integer, ?cursor: String) -> untyped + def get_list: (key: String, ?limit: Integer, ?cursor: String) -> untyped + def update_list: (key: String, ?add_items: Array[String], ?remove_items: Array[String]) -> void + def add_list_item: (key: String, item: String) -> void + def list_contains_item: (key: String, item: String) -> untyped + def delete_list_item: (key: String, item: String) -> void + def delete_list: (key: String) -> void + end +end From 920623a53ac91edad293403a25f6881f256bbd35 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 27 Apr 2026 14:31:10 -0400 Subject: [PATCH 18/18] Refactor --- ruby/examples/admin_e2e.rb | 24 ++++++++++-- ruby/examples/kvstore_e2e.rb | 6 +++ ruby/examples/streams_e2e.rb | 7 ++++ ruby/examples/webhooks_e2e.rb | 8 ++++ ruby/lib/quicknode_sdk.rb | 1 + ruby/lib/quicknode_sdk/clients/admin.rb | 19 +--------- ruby/lib/quicknode_sdk/clients/kvstore.rb | 19 +--------- ruby/lib/quicknode_sdk/clients/streams.rb | 19 +--------- ruby/lib/quicknode_sdk/clients/webhooks.rb | 19 +--------- ruby/lib/quicknode_sdk/native_delegator.rb | 43 ++++++++++++++++++++++ 10 files changed, 89 insertions(+), 76 deletions(-) create mode 100644 ruby/lib/quicknode_sdk/native_delegator.rb diff --git a/ruby/examples/admin_e2e.rb b/ruby/examples/admin_e2e.rb index 566eafa..2fb2cf3 100644 --- a/ruby/examples/admin_e2e.rb +++ b/ruby/examples/admin_e2e.rb @@ -5,11 +5,20 @@ # ── Read-only globals ───────────────────────────────────────────────────────── +# Exercise all three call styles the dispatcher supports against an arity-0 +# native method (list_chains): bare, positional empty hash, and kwargs splat. resp = qn.admin.list_chains puts "list_chains: #{resp[:data].length} chains" +raise "list_chains positional hash broke" unless qn.admin.list_chains({})[:data].length == resp[:data].length +raise "list_chains kwargs splat broke" unless qn.admin.list_chains(**{})[:data].length == resp[:data].length -resp = qn.admin.get_endpoints(limit: 5) -puts "get_endpoints: #{resp[:data].length} endpoints" +# Same coverage for an arity-1 native method (get_endpoints) — this is the +# call shape the README advertises bare, so all three must work. +bare = qn.admin.get_endpoints +kwargs = qn.admin.get_endpoints(limit: 5) +positional = qn.admin.get_endpoints({}) +puts "get_endpoints: #{kwargs[:data].length} endpoints (bare=#{bare[:data].length}, positional=#{positional[:data].length})" +raise "get_endpoints bare vs positional differ" unless bare.keys.sort == positional.keys.sort # Verify symbol- and string-key access work interchangeably. sample = qn.admin.get_endpoints(limit: 1) @@ -17,8 +26,15 @@ raise "indifferent access broken" unless sample[:data] == sample["data"] puts "indifferent access: ok" -resp = qn.admin.get_usage({}) -puts "get_usage: #{resp[:data].inspect}" +# Same three-call-style check for one of the arity-1 read-only usage methods. +# The remaining get_usage_by_* methods share the dispatcher path, so one +# representative is enough to lock in coverage. +usage_bare = qn.admin.get_usage +usage_positional = qn.admin.get_usage({}) +usage_kwargs = qn.admin.get_usage(**{}) +raise "get_usage call styles diverge" unless usage_bare.keys.sort == usage_positional.keys.sort && usage_bare.keys.sort == usage_kwargs.keys.sort +puts "get_usage: #{usage_bare[:data].inspect}" +resp = usage_bare sleep 0.5 diff --git a/ruby/examples/kvstore_e2e.rb b/ruby/examples/kvstore_e2e.rb index 07a5167..f66c491 100644 --- a/ruby/examples/kvstore_e2e.rb +++ b/ruby/examples/kvstore_e2e.rb @@ -10,8 +10,11 @@ set = qn.kvstore.get_set(key: "e2e-set-key") puts "get set: #{set[:value]}" +# get_sets is arity-1 native: exercise bare, kwargs, and positional hash. sets = qn.kvstore.get_sets({}) puts "all sets: #{sets[:data].map { |e| e[:key] }.inspect}" +raise "get_sets bare broke" unless qn.kvstore.get_sets.keys.sort == sets.keys.sort +raise "get_sets kwargs splat broke" unless qn.kvstore.get_sets(**{}).keys.sort == sets.keys.sort qn.kvstore.bulk_sets(delete_sets: ["e2e-set-key"]) puts "bulk sets: deleted e2e-set-key" @@ -30,8 +33,11 @@ list = qn.kvstore.get_list(key: "e2e-list-key") puts "get list items: #{list.dig(:data, :items).inspect}" +# get_lists is arity-1 native: exercise bare, kwargs, and positional hash. lists = qn.kvstore.get_lists({}) puts "all list keys: #{lists.dig(:data, :keys).inspect}" +raise "get_lists bare broke" unless qn.kvstore.get_lists.keys.sort == lists.keys.sort +raise "get_lists kwargs splat broke" unless qn.kvstore.get_lists(**{}).keys.sort == lists.keys.sort qn.kvstore.add_list_item(key: "e2e-list-key", item: "0x123") puts "added list item: 0x123" diff --git a/ruby/examples/streams_e2e.rb b/ruby/examples/streams_e2e.rb index bedfcae..6d83ef1 100644 --- a/ruby/examples/streams_e2e.rb +++ b/ruby/examples/streams_e2e.rb @@ -2,11 +2,18 @@ qn = QuicknodeSdk::SDK.from_env +# Exercise all three call styles the dispatcher supports against arity-1 +# native read-only methods. list_streams/get_enabled_count must work bare, +# with kwargs, and with a positional empty hash. before = qn.streams.list_streams({}) puts "streams before: #{before.dig(:pageInfo, :total)}" +raise "list_streams bare broke" unless qn.streams.list_streams.keys.sort == before.keys.sort +raise "list_streams kwargs splat broke" unless qn.streams.list_streams(**{}).keys.sort == before.keys.sort count = qn.streams.get_enabled_count({}) puts "enabled count: #{count[:total]}" +raise "get_enabled_count bare broke" unless qn.streams.get_enabled_count.keys.sort == count.keys.sort +raise "get_enabled_count kwargs splat broke" unless qn.streams.get_enabled_count(**{}).keys.sort == count.keys.sort filter_result = qn.streams.test_filter( network: "ethereum-mainnet", diff --git a/ruby/examples/webhooks_e2e.rb b/ruby/examples/webhooks_e2e.rb index 61a747c..2af5cc8 100644 --- a/ruby/examples/webhooks_e2e.rb +++ b/ruby/examples/webhooks_e2e.rb @@ -3,11 +3,19 @@ qn = QuicknodeSdk::SDK.from_env +# list_webhooks is arity-1 native: exercise bare, kwargs, and positional hash. before = qn.webhooks.list_webhooks({}) puts "webhooks before: #{before[:data].length} (total=#{before.dig(:pageInfo, :total)})" +raise "list_webhooks bare broke" unless qn.webhooks.list_webhooks.keys.sort == before.keys.sort +raise "list_webhooks kwargs splat broke" unless qn.webhooks.list_webhooks(**{}).keys.sort == before.keys.sort +# get_enabled_count is arity-0 native: exercise bare, positional empty hash, +# and kwargs splat — all three must reach the no-args branch of the +# dispatcher cleanly. count = qn.webhooks.get_enabled_count puts "enabled count: #{count[:total]}" +raise "get_enabled_count positional hash broke" unless qn.webhooks.get_enabled_count({}).keys.sort == count.keys.sort +raise "get_enabled_count kwargs splat broke" unless qn.webhooks.get_enabled_count(**{}).keys.sort == count.keys.sort destination_attributes = JSON.generate({ url: "https://webhook.site/ae19071a-2dcc-4035-9cdf-406dcb4719ef", diff --git a/ruby/lib/quicknode_sdk.rb b/ruby/lib/quicknode_sdk.rb index 846b02c..f1509ce 100644 --- a/ruby/lib/quicknode_sdk.rb +++ b/ruby/lib/quicknode_sdk.rb @@ -1,5 +1,6 @@ require_relative "quicknode_sdk.bundle" require_relative "quicknode_sdk/wrap" +require_relative "quicknode_sdk/native_delegator" require_relative "quicknode_sdk/clients/admin" require_relative "quicknode_sdk/clients/streams" require_relative "quicknode_sdk/clients/webhooks" diff --git a/ruby/lib/quicknode_sdk/clients/admin.rb b/ruby/lib/quicknode_sdk/clients/admin.rb index 695a17d..dc74d59 100644 --- a/ruby/lib/quicknode_sdk/clients/admin.rb +++ b/ruby/lib/quicknode_sdk/clients/admin.rb @@ -1,21 +1,4 @@ module QuicknodeSdk - class Admin - def initialize(native) - @native = native - end - - def method_missing(name, *args, **kwargs) - return super unless @native.respond_to?(name) - result = if kwargs.empty? - @native.public_send(name, *args) - else - @native.public_send(name, **kwargs) - end - QuicknodeSdk.wrap(result) - end - - def respond_to_missing?(name, include_private = false) - @native.respond_to?(name, include_private) || super - end + class Admin < NativeDelegator end end diff --git a/ruby/lib/quicknode_sdk/clients/kvstore.rb b/ruby/lib/quicknode_sdk/clients/kvstore.rb index 51a1c5e..879255e 100644 --- a/ruby/lib/quicknode_sdk/clients/kvstore.rb +++ b/ruby/lib/quicknode_sdk/clients/kvstore.rb @@ -1,21 +1,4 @@ module QuicknodeSdk - class KvStore - def initialize(native) - @native = native - end - - def method_missing(name, *args, **kwargs) - return super unless @native.respond_to?(name) - result = if kwargs.empty? - @native.public_send(name, *args) - else - @native.public_send(name, **kwargs) - end - QuicknodeSdk.wrap(result) - end - - def respond_to_missing?(name, include_private = false) - @native.respond_to?(name, include_private) || super - end + class KvStore < NativeDelegator end end diff --git a/ruby/lib/quicknode_sdk/clients/streams.rb b/ruby/lib/quicknode_sdk/clients/streams.rb index f51168f..c03f531 100644 --- a/ruby/lib/quicknode_sdk/clients/streams.rb +++ b/ruby/lib/quicknode_sdk/clients/streams.rb @@ -1,21 +1,4 @@ module QuicknodeSdk - class Streams - def initialize(native) - @native = native - end - - def method_missing(name, *args, **kwargs) - return super unless @native.respond_to?(name) - result = if kwargs.empty? - @native.public_send(name, *args) - else - @native.public_send(name, **kwargs) - end - QuicknodeSdk.wrap(result) - end - - def respond_to_missing?(name, include_private = false) - @native.respond_to?(name, include_private) || super - end + class Streams < NativeDelegator end end diff --git a/ruby/lib/quicknode_sdk/clients/webhooks.rb b/ruby/lib/quicknode_sdk/clients/webhooks.rb index 5a9b981..8b5ebc1 100644 --- a/ruby/lib/quicknode_sdk/clients/webhooks.rb +++ b/ruby/lib/quicknode_sdk/clients/webhooks.rb @@ -1,21 +1,4 @@ module QuicknodeSdk - class Webhooks - def initialize(native) - @native = native - end - - def method_missing(name, *args, **kwargs) - return super unless @native.respond_to?(name) - result = if kwargs.empty? - @native.public_send(name, *args) - else - @native.public_send(name, **kwargs) - end - QuicknodeSdk.wrap(result) - end - - def respond_to_missing?(name, include_private = false) - @native.respond_to?(name, include_private) || super - end + class Webhooks < NativeDelegator end end diff --git a/ruby/lib/quicknode_sdk/native_delegator.rb b/ruby/lib/quicknode_sdk/native_delegator.rb new file mode 100644 index 0000000..00b0b13 --- /dev/null +++ b/ruby/lib/quicknode_sdk/native_delegator.rb @@ -0,0 +1,43 @@ +module QuicknodeSdk + # Shared base class for the user-facing client wrappers (Admin, Streams, + # Webhooks, KvStore). Each Magnus-bound native client is held in @native and + # all method calls are forwarded through method_missing. + # + # The native side exposes two kinds of methods: arity-0 (e.g. list_chains) + # and arity-1 taking a single positional Hash of options (e.g. + # get_endpoints). To support all three Ruby call styles documented in the + # README and examples — bare (qn.admin.get_endpoints), kwargs + # (qn.admin.get_endpoints(limit: 5)), and positional hash + # (qn.streams.list_streams({})) — we coerce whatever the caller passed into + # a single options hash, then dispatch on the native arity. Arity-0 methods + # reject any argument (Magnus enforces this), so we must call them with no + # args; arity-1 methods always need a Hash passed POSITIONALLY (Magnus's + # RHash is a positional arg, and Ruby 3 treats `**{}` as zero arguments — + # so we must not splat the options). + class NativeDelegator + def initialize(native) + @native = native + end + + def method_missing(name, *args, **kwargs) + return super unless @native.respond_to?(name) + opts = if !kwargs.empty? + kwargs + elsif args.length == 1 && args[0].is_a?(Hash) + args[0] + else + {} + end + result = if @native.method(name).arity == 0 + @native.public_send(name) + else + @native.public_send(name, opts) + end + QuicknodeSdk.wrap(result) + end + + def respond_to_missing?(name, include_private = false) + @native.respond_to?(name, include_private) || super + end + end +end