From 167037b247df6bf0740dc123c274e5e7d6a94d16 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Mon, 1 Jun 2026 13:32:20 -0400 Subject: [PATCH] fix(node): preserve camelCase keys for webhook template args The Node binding's node_ta_to_core was snake-casing the inner `args` object before deserializing into core::webhooks::TemplateArgs. Core's EvmContractEventsTemplate carries #[serde(rename_all = "camelCase")] and expects `eventHashes` on input, so the snake-cased `event_hashes` was treated as an unknown key and silently dropped. With the field gone, the outbound serializer omitted it (skip_serializing_if = "Option::is_none"), and the API rejected the request with HTTP 500 ("Expected array for arg eventHashes") whether or not the caller supplied the field. Webhook template inner structs are modeled around the API wire format and don't need per-field case conversion (unlike streams destinations, whose core structs use plain Rust snake_case). All other template structs only have single-word fields where the two cases are identical, so removing the conversion is safe across templates. Adds a regression test in the Node crate that exercises node_ta_to_core directly with eventHashes input and asserts the outbound JSON contains "eventHashes" with the expected value, plus end-to-end coverage of TemplateArgs::EvmContractEvents in the webhooks examples for all four languages. Also adds a CLAUDE.md note discouraging blanket clippy::panic / clippy::unreachable allowances in tests; prefer matches! / let-else-unreachable. --- CLAUDE.md | 1 + crates/core/examples/webhooks_e2e.rs | 42 +++++++++++++- crates/node/src/webhooks_template.rs | 84 +++++++++++++++++++++++++--- npm/examples/webhooks_e2e.ts | 21 +++++++ python/examples/webhooks_e2e.py | 23 ++++++++ ruby/examples/webhooks_e2e.rb | 20 +++++++ 6 files changed, 179 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 066a008..a9422f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -178,6 +178,7 @@ Core clients are tested using mocked API calls with wiremock. All functions maki ### Error handling - Library constructors should return `Result`, not panic — use `.unwrap()` or `.expect()` only in examples and tests, never in library code +- Do not silence `clippy::panic` (or `clippy::unreachable`) with `#[allow(...)]` unless explicitly directed. If a test needs to fail on an unexpected enum variant, prefer `assert!(matches!(...))` or `let ... else { unreachable!() }` over a `match` arm that calls `panic!`. - Validate numeric config values before casting between signed/unsigned types (e.g., check `>= 0` before `i64 as u64`) - Map `SdkError` at the binding boundary only — keep core code returning `Result<_, SdkError>`, never a language-specific exception type. See the Error Handling section above for the typed exception hierarchy and how to add a new variant. - When a binding needs new error metadata (status, body, retry info, etc.), add it to the `SdkError` variant first, then surface it on the exception class in each binding (PyO3 `setattr`, Ruby `ivar_set`, Node tagged-message prefix). diff --git a/crates/core/examples/webhooks_e2e.rs b/crates/core/examples/webhooks_e2e.rs index 8ac6f20..0e70c49 100644 --- a/crates/core/examples/webhooks_e2e.rs +++ b/crates/core/examples/webhooks_e2e.rs @@ -2,9 +2,9 @@ use std::{thread, time::Duration}; use quicknode_sdk::{ webhooks::{ - ActivateWebhookParams, CreateWebhookFromTemplateParams, EvmWalletFilterTemplate, - GetWebhooksParams, TemplateArgs, UpdateWebhookParams, WebhookDestinationAttributes, - WebhookStartFrom, + ActivateWebhookParams, CreateWebhookFromTemplateParams, EvmContractEventsTemplate, + EvmWalletFilterTemplate, GetWebhooksParams, TemplateArgs, UpdateWebhookParams, + WebhookDestinationAttributes, WebhookStartFrom, }, QuicknodeSdk, SdkFullConfig, }; @@ -98,6 +98,42 @@ async fn main() { println!("deleted: {id}"); thread::sleep(Duration::from_secs(1)); + // Exercise the evm-contract-events template, which carries the multi-word + // `event_hashes` field. The API expects `eventHashes` on the wire. + let contract_events_args = TemplateArgs::EvmContractEvents(EvmContractEventsTemplate { + contracts: vec!["0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string()], + event_hashes: Some(vec![ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef".to_string(), + ]), + }); + let contract_events_params = CreateWebhookFromTemplateParams { + name: "E2E Test Webhook (evmContractEvents)".to_string(), + network: "ethereum-mainnet".to_string(), + notification_email: None, + destination_attributes: WebhookDestinationAttributes { + url: "https://webhook.site/ae19071a-2dcc-4035-9cdf-406dcb4719ef".to_string(), + security_token: None, + compression: None, + }, + template_args: contract_events_args, + }; + let ce_webhook = qn + .webhooks + .create_webhook_from_template(&contract_events_params) + .await + .expect("create_webhook_from_template (evmContractEvents) failed"); + println!( + "created (evmContractEvents): {} | {}", + ce_webhook.id, ce_webhook.status + ); + thread::sleep(Duration::from_secs(1)); + qn.webhooks + .delete_webhook(&ce_webhook.id) + .await + .expect("delete_webhook (evmContractEvents) failed"); + println!("deleted (evmContractEvents): {}", ce_webhook.id); + thread::sleep(Duration::from_secs(1)); + let after = qn .webhooks .list_webhooks(&GetWebhooksParams::default()) diff --git a/crates/node/src/webhooks_template.rs b/crates/node/src/webhooks_template.rs index c629647..e06e4f6 100644 --- a/crates/node/src/webhooks_template.rs +++ b/crates/node/src/webhooks_template.rs @@ -2,8 +2,6 @@ use napi::bindgen_prelude::*; use napi_derive::napi; use quicknode_sdk as core; -use crate::key_case::{camel_to_snake, convert_keys}; - // napi(object) cannot represent the flattened TemplateArgs enum on core's // webhook params, so these node-facing params carry template_args as // serde_json::Value. @@ -13,13 +11,15 @@ use crate::key_case::{camel_to_snake, convert_keys}; // `templateArgs.templateArgs.wallets` in TypeScript. node_ta_to_core() // renames it back before deserializing. // -// Keys inside `args` also need case conversion: TypeScript callers write -// camelCase (eventHashes), but core's serde structs expect snake_case -// (event_hashes). napi does this automatically for #[napi(object)] structs, -// but a raw serde_json::Value bypasses that — so we walk the inner object -// here. +// Unlike streams_destination.rs, the inner keys are NOT case-converted. +// Webhook template structs in core are modeled around the API wire format +// (camelCase: e.g. EvmContractEventsTemplate carries +// `#[serde(rename_all = "camelCase")]` so it expects `eventHashes`, not +// `event_hashes`). All other template structs only have single-word fields +// that are identical in either case. Snake-casing the input here would +// silently drop multi-word fields like `eventHashes`. -fn node_ta_to_core(v: serde_json::Value) -> Result { +pub(crate) fn node_ta_to_core(v: serde_json::Value) -> Result { let mut obj = match v { serde_json::Value::Object(o) => o, _ => { @@ -31,7 +31,6 @@ fn node_ta_to_core(v: serde_json::Value) -> Result let args = obj .remove("args") .ok_or_else(|| Error::from_reason("templateArgs.args is required".to_string()))?; - let args = convert_keys(args, camel_to_snake); // Core's tag key is `templateId`, content key is `templateArgs`. Input // already has `templateId`; rename `args` -> `templateArgs`. obj.insert("templateArgs".to_string(), args); @@ -81,3 +80,70 @@ impl UpdateWebhookTemplateParamsNode { }) } } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn evm_contract_events_preserves_event_hashes_through_outbound_wire() { + let input = json!({ + "templateId": "evmContractEvents", + "args": { + "contracts": ["0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"], + "eventHashes": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" + ], + }, + }); + + let parsed = node_ta_to_core(input).unwrap(); + let core::webhooks::TemplateArgs::EvmContractEvents(t) = &parsed else { + unreachable!("expected EvmContractEvents variant") + }; + assert_eq!( + t.event_hashes.as_deref(), + Some( + [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" + .to_string() + ] + .as_slice() + ), + ); + + let params = core::webhooks::CreateWebhookFromTemplateParams { + name: "t".to_string(), + network: "ethereum-mainnet".to_string(), + notification_email: None, + destination_attributes: core::webhooks::WebhookDestinationAttributes { + url: "https://x".to_string(), + security_token: None, + compression: None, + }, + template_args: parsed, + }; + let outbound = serde_json::to_value(¶ms).unwrap(); + let template_args = outbound.get("templateArgs").unwrap(); + assert_eq!( + template_args["eventHashes"][0].as_str(), + Some("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef") + ); + assert!(template_args.get("event_hashes").is_none()); + } + + #[test] + fn evm_wallet_filter_single_word_field_still_works() { + let input = json!({ + "templateId": "evmWalletFilter", + "args": { "wallets": ["0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"] }, + }); + let parsed = node_ta_to_core(input).unwrap(); + assert!(matches!( + parsed, + core::webhooks::TemplateArgs::EvmWalletFilter(_) + )); + } +} diff --git a/npm/examples/webhooks_e2e.ts b/npm/examples/webhooks_e2e.ts index 1080ec7..cd140e6 100644 --- a/npm/examples/webhooks_e2e.ts +++ b/npm/examples/webhooks_e2e.ts @@ -49,6 +49,27 @@ async function main() { console.log(`deleted: ${id}`); await sleep(1000); + // Exercise the evm-contract-events template, which carries the multi-word + // eventHashes field. The API expects eventHashes on the wire. + const ceCreateParams: CreateWebhookFromTemplateParams = { + name: "E2E Test Webhook (evmContractEvents)", + network: "ethereum-mainnet", + destinationAttributes: { + url: "https://webhook.site/ae19071a-2dcc-4035-9cdf-406dcb4719ef", + }, + templateArgs: TemplateArgs.evmContractEvents({ + contracts: ["0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"], + eventHashes: [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + ], + }), + }; + const ceWebhook = await qn.webhooks.createWebhookFromTemplate(ceCreateParams); + console.log(`created (evmContractEvents): ${ceWebhook.id} | ${ceWebhook.status}`); + await qn.webhooks.deleteWebhook(ceWebhook.id); + console.log(`deleted (evmContractEvents): ${ceWebhook.id}`); + await sleep(1000); + const after = await qn.webhooks.listWebhooks(); console.log(`webhooks after: ${after.data.length} (total=${after.pageInfo.total})`); } diff --git a/python/examples/webhooks_e2e.py b/python/examples/webhooks_e2e.py index ffbe479..a404488 100644 --- a/python/examples/webhooks_e2e.py +++ b/python/examples/webhooks_e2e.py @@ -1,6 +1,8 @@ import asyncio from quicknode_sdk import ( + EvmContractEventsArgs, + EvmContractEventsTemplate, EvmWalletFilterArgs, EvmWalletFilterTemplate, QuicknodeSdk, @@ -45,6 +47,27 @@ async def main(): await qn.webhooks.delete_webhook(webhook_id) print(f"deleted: {webhook_id}") + # Exercise the evm-contract-events template, which carries the multi-word + # event_hashes field. The API expects eventHashes on the wire. + ce_webhook = await qn.webhooks.create_webhook_from_template( + name="E2E Test Webhook (evmContractEvents)", + network="ethereum-mainnet", + destination_attributes=WebhookDestinationAttributes( + url="https://webhook.site/ae19071a-2dcc-4035-9cdf-406dcb4719ef", + ), + template_args=EvmContractEventsArgs( + EvmContractEventsTemplate( + contracts=["0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"], + event_hashes=[ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" + ], + ) + ), + ) + print(f"created (evmContractEvents): {ce_webhook.id} | {ce_webhook.status}") + await qn.webhooks.delete_webhook(ce_webhook.id) + print(f"deleted (evmContractEvents): {ce_webhook.id}") + after = await qn.webhooks.list_webhooks() print(f"webhooks after: {len(after.data)} (total={after.page_info.total})") diff --git a/ruby/examples/webhooks_e2e.rb b/ruby/examples/webhooks_e2e.rb index 2af5cc8..0d0177f 100644 --- a/ruby/examples/webhooks_e2e.rb +++ b/ruby/examples/webhooks_e2e.rb @@ -52,5 +52,25 @@ puts "deleted: #{id}" sleep 1 +# Exercise the evm-contract-events template, which carries the multi-word +# eventHashes field. The API expects eventHashes on the wire. +contract_events_template_args = JSON.generate({ + templateId: "evmContractEvents", + templateArgs: { + contracts: ["0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"], + eventHashes: ["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"] + } +}) +ce_webhook = qn.webhooks.create_webhook_from_template( + name: "E2E Test Webhook (evmContractEvents)", + network: "ethereum-mainnet", + destination_attributes_json: destination_attributes, + template_args_json: contract_events_template_args +) +puts "created (evmContractEvents): #{ce_webhook[:id]} | #{ce_webhook[:status]}" +qn.webhooks.delete_webhook(id: ce_webhook[:id]) +puts "deleted (evmContractEvents): #{ce_webhook[:id]}" +sleep 1 + after = qn.webhooks.list_webhooks({}) puts "webhooks after: #{after[:data].length} (total=#{after.dig(:pageInfo, :total)})"