Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
42 changes: 39 additions & 3 deletions crates/core/examples/webhooks_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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())
Expand Down
84 changes: 75 additions & 9 deletions crates/node/src/webhooks_template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<core::webhooks::TemplateArgs> {
pub(crate) fn node_ta_to_core(v: serde_json::Value) -> Result<core::webhooks::TemplateArgs> {
let mut obj = match v {
serde_json::Value::Object(o) => o,
_ => {
Expand All @@ -31,7 +31,6 @@ fn node_ta_to_core(v: serde_json::Value) -> Result<core::webhooks::TemplateArgs>
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);
Expand Down Expand Up @@ -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(&params).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(_)
));
}
}
21 changes: 21 additions & 0 deletions npm/examples/webhooks_e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})`);
}
Expand Down
23 changes: 23 additions & 0 deletions python/examples/webhooks_e2e.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import asyncio

from quicknode_sdk import (
EvmContractEventsArgs,
EvmContractEventsTemplate,
EvmWalletFilterArgs,
EvmWalletFilterTemplate,
QuicknodeSdk,
Expand Down Expand Up @@ -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})")

Expand Down
20 changes: 20 additions & 0 deletions ruby/examples/webhooks_e2e.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)})"
Loading