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
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,9 @@ Core clients are tested using mocked API calls with wiremock. All functions maki

### Polyglot consistency
- When adding a new public type to `crates/core`, export it across all four layers: Rust re-exports in `lib.rs`, Python `__init__.py` + `init_manual_override.pyi`, TypeScript `sdk.d.ts`, and the Ruby binding in `crates/ruby/src/lib.rs`
- **Discriminated unions (Rust enum-with-data)** cannot be annotated with `#[pyclass]` or `#[napi(object)]`. Pattern: keep the enum pure-Rust in `crates/core` with `#[serde(tag = "...", content = "...")]`, then in each binding crate provide a typed surface that converts to/from the enum at the FFI boundary. Any core struct that flattens such an enum (e.g. via `#[serde(flatten)]`) also loses its `#[pyclass]` / `#[napi(object)]` and needs a wrapper in each binding. See `crates/core/src/streams/stream.rs::DestinationAttributes` and `crates/{python,node}/src/streams_destination.rs` for the reference implementation.
- **Discriminated unions (Rust enum-with-data)** cannot be annotated with `#[pyclass]` or `#[napi(object)]`. Pattern: keep the enum pure-Rust in `crates/core` with `#[serde(tag = "...", content = "...")]`, then in each binding crate provide a typed surface that converts to/from the enum at the FFI boundary. Any core struct that flattens such an enum (e.g. via `#[serde(flatten)]`) also loses its `#[pyclass]` / `#[napi(object)]` and needs a wrapper in each binding. Reference implementations:
- `crates/core/src/streams/stream.rs::DestinationAttributes` — stream destinations, wrapped by `crates/{python,node}/src/streams_destination.rs`.
- `crates/core/src/webhooks/webhook.rs::TemplateArgs` — webhook filter templates, wrapped by `crates/{python,node}/src/webhooks_template.rs`. Ruby accepts the wire JSON directly (`{"templateId":..., "templateArgs":...}`) and relies on serde to deserialize into the enum.
- `python/sdk/__init__.py` is **manually maintained** — it is NOT auto-generated. Every new public struct/type must be added to both the `from sdk._core import (...)` block and the `__all__` list in this file
- When adding a new type with `#[cfg_attr(feature = "node", napi(object))]`, also add it to the named `export type { ... }` block in `npm/sdk.d.ts` — this is the user-facing type file and is not auto-updated by napi-rs
- When adding a new `#[napi(string_enum)]` Rust enum, it generates a TypeScript `const enum` in `npm/index.d.ts`. In `npm/sdk.d.ts`, these must be re-exported using a regular `export { ... }` (not `export type { ... }`), otherwise TypeScript consumers cannot use them as values (e.g., `StreamDataset.Block`)
Expand Down
26 changes: 13 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2542,7 +2542,7 @@ Paginated list of webhooks.

**Parameters** (all optional): `limit` (i64), `offset` (i64).

**Returns**: `ListWebhooksResponse` with `data: Webhook[]` and `pageInfo`.
**Returns**: `ListWebhooksResponse` with `data: Webhook[]` and `pageInfo: WebhookPageInfo { limit, offset, total }`.

```rust
// Rust
Expand Down Expand Up @@ -2596,15 +2596,15 @@ webhook = JSON.parse(qn.webhooks.get_webhook(id: "wh-1"))

Creates a webhook from a predefined filter template.

**Parameters**: `name` (required), `network` (required), `destination_attributes` (`WebhookDestinationAttributes`, required), `template_args` (`TemplateArgs`, required), `notification_email` (optional).
**Parameters**: `name` (required), `network` (required), `destination_attributes` (`WebhookDestinationAttributes`, required), `template_args` (required — use the `TemplateArgs` enum variant for the chosen template), `notification_email` (optional).

**Returns**: `Webhook`.

```rust
// Rust
let template_args = TemplateArgs::evm_wallet_filter(&EvmWalletFilterTemplate {
let template_args = TemplateArgs::EvmWalletFilter(EvmWalletFilterTemplate {
wallets: vec!["0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string()],
})?;
});
let params = CreateWebhookFromTemplateParams {
name: "Wallet Webhook".to_string(),
network: "ethereum-mainnet".to_string(),
Expand All @@ -2621,13 +2621,13 @@ let webhook = qn.webhooks.create_webhook_from_template(&params).await?;

```python
# Python
from sdk import EvmWalletFilterTemplate, TemplateArgs, WebhookDestinationAttributes
from sdk import EvmWalletFilterArgs, EvmWalletFilterTemplate, WebhookDestinationAttributes

webhook = await qn.webhooks.create_webhook_from_template(
name="Wallet Webhook",
network="ethereum-mainnet",
destination_attributes=WebhookDestinationAttributes(url="https://webhook.site/..."),
template_args=TemplateArgs.evm_wallet_filter(
template_args=EvmWalletFilterArgs(
EvmWalletFilterTemplate(wallets=["0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"])
),
)
Expand All @@ -2654,8 +2654,8 @@ destination_attributes = JSON.generate({
compression: "none"
})
template_args = JSON.generate({
template_id: "evmWalletFilter",
value: JSON.generate({ wallets: ["0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"] })
templateId: "evmWalletFilter",
templateArgs: { wallets: ["0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"] }
})
webhook = JSON.parse(qn.webhooks.create_webhook_from_template(
name: "Wallet Webhook",
Expand Down Expand Up @@ -2707,9 +2707,9 @@ Updates the template args (and optionally name, email, destination) on an existi

```rust
// Rust
let template_args = TemplateArgs::evm_wallet_filter(&EvmWalletFilterTemplate {
let template_args = TemplateArgs::EvmWalletFilter(EvmWalletFilterTemplate {
wallets: vec!["0xnewwallet".to_string()],
})?;
});
let params = UpdateWebhookTemplateParams {
name: None,
notification_email: None,
Expand All @@ -2723,7 +2723,7 @@ let webhook = qn.webhooks.update_webhook_template("wh-1", &params).await?;
# Python
webhook = await qn.webhooks.update_webhook_template(
"wh-1",
template_args=TemplateArgs.evm_wallet_filter(
template_args=EvmWalletFilterArgs(
EvmWalletFilterTemplate(wallets=["0xnewwallet"])
),
)
Expand All @@ -2739,8 +2739,8 @@ const webhook = await qn.webhooks.updateWebhookTemplate("wh-1", {
```ruby
# Ruby
template_args = JSON.generate({
template_id: "evmWalletFilter",
value: JSON.generate({ wallets: ["0xnewwallet"] })
templateId: "evmWalletFilter",
templateArgs: { wallets: ["0xnewwallet"] }
})
webhook = JSON.parse(qn.webhooks.update_webhook_template(
webhook_id: "wh-1",
Expand Down
17 changes: 12 additions & 5 deletions crates/core/examples/webhooks_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ async fn main() {
.list_webhooks(&GetWebhooksParams::default())
.await
.expect("list_webhooks failed");
println!("webhooks before: {}", before.data.len());
println!(
"webhooks before: {} (total={})",
before.data.len(),
before.page_info.total
);

let count = qn
.webhooks
Expand All @@ -29,10 +33,9 @@ async fn main() {
.expect("get_enabled_count failed");
println!("enabled count: {}", count.total);

let template_args = TemplateArgs::evm_wallet_filter(&EvmWalletFilterTemplate {
let template_args = TemplateArgs::EvmWalletFilter(EvmWalletFilterTemplate {
wallets: vec!["0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".to_string()],
})
.expect("template args are valid");
});

let create_params = CreateWebhookFromTemplateParams {
name: "E2E Test Webhook".to_string(),
Expand Down Expand Up @@ -100,5 +103,9 @@ async fn main() {
.list_webhooks(&GetWebhooksParams::default())
.await
.expect("list_webhooks failed");
println!("webhooks after: {}", after.data.len());
println!(
"webhooks after: {} (total={})",
after.data.len(),
after.page_info.total
);
}
67 changes: 23 additions & 44 deletions crates/core/src/webhooks/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ pub use webhook::{
HyperliquidWalletEventsFilterTemplate, ListWebhooksResponse, SolanaWalletFilterTemplate,
StellarWalletTransactionsFilterTemplate, TemplateArgs, UpdateWebhookParams,
UpdateWebhookTemplateParams, Webhook, WebhookDestinationAttributes,
WebhookEnabledCountResponse, WebhookStartFrom, WebhookTemplateId, XrplWalletFilterTemplate,
WebhookEnabledCountResponse, WebhookPageInfo, WebhookStartFrom, WebhookTemplateId,
XrplWalletFilterTemplate,
};

use crate::{config::WebhooksConfig, errors::SdkError, SdkConfig};
Expand Down Expand Up @@ -263,30 +264,17 @@ impl WebhooksApiClient {
&self,
params: &CreateWebhookFromTemplateParams,
) -> Result<Webhook, SdkError> {
let template_id = params.template_args.template_id.as_str();
let template_id = params.template_args.tag().as_str();
let url = self
.config
.webhooks()
.base_url
.join(&format!("webhooks/template/{template_id}"))?;

#[allow(clippy::needless_borrows_for_generic_args)]
let mut body =
serde_json::to_value(&params).map_err(|e| SdkError::Config(e.to_string()))?;
let obj = body.as_object_mut().ok_or_else(|| {
SdkError::Config("failed to serialize request body as JSON object".into())
})?;
obj.insert(
"templateArgs".to_string(),
serde_json::from_str(&params.template_args.value)
.map_err(|e| SdkError::Config(e.to_string()))?,
);

let resp = self
.config
.http_client()
.post(url)
.json(&body)
.json(params)
.send()
.await
.map_err(SdkError::Http)?;
Expand All @@ -309,30 +297,17 @@ impl WebhooksApiClient {
webhook_id: &str,
params: &UpdateWebhookTemplateParams,
) -> Result<Webhook, SdkError> {
let template_id = params.template_args.template_id.as_str();
let template_id = params.template_args.tag().as_str();
let url = self
.config
.webhooks()
.base_url
.join(&format!("webhooks/{webhook_id}/template/{template_id}"))?;

#[allow(clippy::needless_borrows_for_generic_args)]
let mut body =
serde_json::to_value(&params).map_err(|e| SdkError::Config(e.to_string()))?;
let obj = body.as_object_mut().ok_or_else(|| {
SdkError::Config("failed to serialize request body as JSON object".into())
})?;
obj.insert(
"templateArgs".to_string(),
serde_json::from_str(&params.template_args.value)
.map_err(|e| SdkError::Config(e.to_string()))?,
);

let resp = self
.config
.http_client()
.patch(url)
.json(&body)
.json(params)
.send()
.await
.map_err(SdkError::Http)?;
Expand Down Expand Up @@ -384,7 +359,12 @@ mod tests {
async fn list_webhooks_success() {
let server = MockServer::start().await;
let response = serde_json::json!({
"data": [webhook_response_json()]
"data": [webhook_response_json()],
"pageInfo": {
"limit": 20,
"offset": 0,
"total": 1,
}
});
Mock::given(method("GET"))
.and(path("/webhooks"))
Expand All @@ -399,6 +379,9 @@ mod tests {
.unwrap();
assert_eq!(resp.data.len(), 1);
assert_eq!(resp.data[0].id, "wh-1234-5678");
assert_eq!(resp.page_info.limit, 20);
assert_eq!(resp.page_info.offset, 0);
assert_eq!(resp.page_info.total, 1);
}

#[tokio::test]
Expand Down Expand Up @@ -650,10 +633,9 @@ mod tests {
.mount(&server)
.await;
let sdk = make_sdk(format!("{}/", server.uri()));
let template_args = TemplateArgs::evm_wallet_filter(&EvmWalletFilterTemplate {
let template_args = TemplateArgs::EvmWalletFilter(EvmWalletFilterTemplate {
wallets: vec!["0xabc".to_string()],
})
.unwrap();
});
let params = CreateWebhookFromTemplateParams {
name: "test-webhook".to_string(),
network: "ethereum-mainnet".to_string(),
Expand Down Expand Up @@ -682,10 +664,9 @@ mod tests {
.mount(&server)
.await;
let sdk = make_sdk(format!("{}/", server.uri()));
let template_args = TemplateArgs::evm_wallet_filter(&EvmWalletFilterTemplate {
let template_args = TemplateArgs::EvmWalletFilter(EvmWalletFilterTemplate {
wallets: vec!["0xabc".to_string()],
})
.unwrap();
});
let params = CreateWebhookFromTemplateParams {
name: "test-webhook".to_string(),
network: "ethereum-mainnet".to_string(),
Expand Down Expand Up @@ -714,10 +695,9 @@ mod tests {
.mount(&server)
.await;
let sdk = make_sdk(format!("{}/", server.uri()));
let template_args = TemplateArgs::evm_wallet_filter(&EvmWalletFilterTemplate {
let template_args = TemplateArgs::EvmWalletFilter(EvmWalletFilterTemplate {
wallets: vec!["0xabc".to_string()],
})
.unwrap();
});
let params = UpdateWebhookTemplateParams {
name: None,
notification_email: None,
Expand All @@ -741,10 +721,9 @@ mod tests {
.mount(&server)
.await;
let sdk = make_sdk(format!("{}/", server.uri()));
let template_args = TemplateArgs::evm_wallet_filter(&EvmWalletFilterTemplate {
let template_args = TemplateArgs::EvmWalletFilter(EvmWalletFilterTemplate {
wallets: vec!["0xabc".to_string()],
})
.unwrap();
});
let params = UpdateWebhookTemplateParams {
name: None,
notification_email: None,
Expand Down
Loading
Loading