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)})"