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 @@ -197,3 +197,4 @@ Core clients are tested using mocked API calls with wiremock. All functions maki

### Comments
- When doing anything out of the ordinary or breaking conventions or patterns, add a comment explaining the "why" behind it
- Do not reference Linear issue IDs (e.g. `DX-1234`), PR numbers, ticket URLs, or other internal tracking identifiers in code comments. The "why" should be self-contained — describe the constraint or behaviour itself, not where to look it up. Internal context belongs in the commit message or PR description.
4 changes: 2 additions & 2 deletions crates/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -836,7 +836,7 @@ Returns metric series for an endpoint over a time period.

**Parameters**: `id` (endpoint id, required); body: `period` (`"hour"` | `"day"` | `"week"` | `"month"`), `metric` (e.g. `"method_calls_over_time"`, `"response_status_breakdown"`).

**Returns**: `GetEndpointMetricsResponse` with `data: EndpointMetric[]`.
**Returns**: `GetEndpointMetricsResponse` with `data: Vec<EndpointMetric>`. Each `EndpointMetric` has `tag: Vec<String>` and `data: Vec<Vec<i64>>` of `[timestamp, value]` pairs. Single-axis series (e.g. `response_time_over_time` with a percentile) come back as a one-element tag like `vec!["p95"]`; multi-axis series come back as `vec!["network", "arbitrum-mainnet"]`.

```rust
// Rust
Expand All @@ -853,7 +853,7 @@ Returns account-level metric series. Supports an optional `percentile` (e.g. `"p

**Parameters**: `period` (required), `metric` (required), `percentile` (string, optional).

**Returns**: `GetAccountMetricsResponse` with `data: EndpointMetric[]`.
**Returns**: `GetAccountMetricsResponse` with `data: Vec<EndpointMetric>`. See `get_endpoint_metrics` above for the `tag: Vec<String>` shape.

```rust
// Rust
Expand Down
24 changes: 22 additions & 2 deletions crates/core/examples/admin_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,17 @@ async fn main() {
})
.await
{
Ok(resp) => println!("get_account_metrics: {} series", resp.data.len()),
Ok(resp) => {
let first = resp
.data
.first()
.map(|m| m.tag.join(":"))
.unwrap_or_else(|| "<none>".to_string());
println!(
"get_account_metrics: {} series, first tag: {first}",
resp.data.len()
);
}
Err(e) => eprintln!("get_account_metrics error: {e}"),
}

Expand Down Expand Up @@ -254,7 +264,17 @@ async fn main() {
)
.await
{
Ok(resp) => println!("get_endpoint_metrics: {} series", resp.data.len()),
Ok(resp) => {
let first = resp
.data
.first()
.map(|m| m.tag.join(":"))
.unwrap_or_else(|| "<none>".to_string());
println!(
"get_endpoint_metrics: {} series, first tag: {first}",
resp.data.len()
);
}
Err(e) => eprintln!("get_endpoint_metrics error: {e}"),
}

Expand Down
2 changes: 1 addition & 1 deletion crates/core/examples/streams_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ async fn main() {
network: "ethereum-mainnet".to_string(),
dataset: StreamDataset::Block,
block: "17811625".to_string(),
filter_function: Some("ZnVuY3Rpb24gbWFpbihkYXRhKSB7IHJldHVybiBkYXRhOyB9".to_string()),
filter_function: "ZnVuY3Rpb24gbWFpbihkYXRhKSB7IHJldHVybiBkYXRhOyB9".to_string(),
filter_language: None,
address_book_config: None,
};
Expand Down
82 changes: 79 additions & 3 deletions crates/core/src/admin/endpoint_metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,34 @@ use napi_derive::napi;
use pyo3::pyclass;
#[cfg(feature = "python")]
use pyo3_stub_gen::derive::gen_stub_pyclass;
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};

// The metrics endpoints return `tag` as either a plain string (single-axis
// series like `"total"` or `"p95"`) or a tuple like `["network", "mainnet"]`
// (multi-axis series). Normalise both to a `Vec<String>` so callers always
// see an array.
fn tag_as_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::Error;
match serde_json::Value::deserialize(deserializer)? {
serde_json::Value::String(s) => Ok(vec![s]),
serde_json::Value::Array(items) => items
.into_iter()
.map(|v| match v {
serde_json::Value::String(s) => Ok(s),
other => Err(D::Error::custom(format!(
"expected string in tag array, got {other}"
))),
})
.collect(),
serde_json::Value::Null => Ok(Vec::new()),
other => Err(D::Error::custom(format!(
"expected string or array of strings for tag, got {other}"
))),
}
}

/// Parameters for `get_endpoint_metrics`.
#[cfg_attr(feature = "python", gen_stub_pyclass)]
Expand Down Expand Up @@ -41,8 +68,11 @@ pub struct GetAccountMetricsRequest {
pub struct EndpointMetric {
/// Data points, each as `[timestamp, value]`.
pub data: Vec<Vec<i64>>,
/// Human-readable tag identifying the series.
pub tag: String,
/// Tag identifying the series. Single-axis metrics return a one-element
/// vector (e.g. `["total"]`, `["p95"]`); multi-axis metrics return the
/// key/value pair (e.g. `["network", "arbitrum-mainnet"]`).
#[serde(deserialize_with = "tag_as_vec")]
pub tag: Vec<String>,
}

/// Response from `get_endpoint_metrics`.
Expand Down Expand Up @@ -70,3 +100,49 @@ pub struct GetAccountMetricsResponse {
/// Error message when the request did not succeed.
pub error: Option<String>,
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::EndpointMetric;

#[test]
fn tag_deserializes_from_string() {
let m: EndpointMetric =
serde_json::from_str(r#"{"data": [[1, 2]], "tag": "total"}"#).unwrap();
assert_eq!(m.tag, vec!["total".to_string()]);
}

#[test]
fn tag_deserializes_from_tuple() {
let m: EndpointMetric =
serde_json::from_str(r#"{"data": [[1, 2]], "tag": ["network", "arbitrum-mainnet"]}"#)
.unwrap();
assert_eq!(
m.tag,
vec!["network".to_string(), "arbitrum-mainnet".to_string()]
);
}

#[test]
fn tag_deserializes_from_null() {
let m: EndpointMetric = serde_json::from_str(r#"{"data": [[1, 2]], "tag": null}"#).unwrap();
assert!(m.tag.is_empty());
}

#[test]
fn tag_rejects_mixed_array() {
let err =
serde_json::from_str::<EndpointMetric>(r#"{"data": [], "tag": ["x", 5]}"#).unwrap_err();
assert!(err.to_string().contains("expected string in tag array"));
}

#[test]
fn tag_rejects_object() {
let err = serde_json::from_str::<EndpointMetric>(r#"{"data": [], "tag": {"k": "v"}}"#)
.unwrap_err();
assert!(err
.to_string()
.contains("expected string or array of strings for tag"));
}
}
71 changes: 69 additions & 2 deletions crates/core/src/admin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2638,7 +2638,7 @@ mod tests {
Mock::given(method("GET"))
.and(path("/endpoints/ep123/metrics"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{"data": [[1700000000, 42]], "tag": "mainnet"}],
"data": [{"data": [[1700000000, 42]], "tag": ["network", "mainnet"]}],
"error": null
})))
.mount(&server)
Expand All @@ -2655,7 +2655,10 @@ mod tests {
.await
.unwrap();
assert_eq!(resp.data.len(), 1);
assert_eq!(resp.data[0].tag, "mainnet");
assert_eq!(
resp.data[0].tag,
vec!["network".to_string(), "mainnet".to_string()]
);
}

#[tokio::test]
Expand All @@ -2679,6 +2682,47 @@ mod tests {
};
let resp = sdk.admin.get_account_metrics(&params).await.unwrap();
assert_eq!(resp.data.len(), 1);
assert_eq!(resp.data[0].tag, vec!["total".to_string()]);
}

// Regression: the metrics endpoints return `tag` as either a plain string
// (single-axis series) or a `[key, value]` tuple (multi-axis series).
// Exercise both shapes so any future serde change that breaks either
// branch fails loudly.
#[tokio::test]
async fn get_account_metrics_decodes_tuple_tag() {
let server = MockServer::start().await;

Mock::given(method("GET"))
.and(path("/metrics"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [
{"tag": ["network", "arbitrum-mainnet"], "data": [[1779109200, 40]]},
{"tag": ["network", "mainnet"], "data": [[1779116400, 40]]},
{"tag": "p95", "data": [[1779116400, 12]]}
],
"error": null
})))
.mount(&server)
.await;

let sdk = make_sdk(format!("{}/", server.uri()));
let params = GetAccountMetricsRequest {
period: "day".to_string(),
metric: "credits_over_time".to_string(),
percentile: None,
};
let resp = sdk.admin.get_account_metrics(&params).await.unwrap();
assert_eq!(resp.data.len(), 3);
assert_eq!(
resp.data[0].tag,
vec!["network".to_string(), "arbitrum-mainnet".to_string()]
);
assert_eq!(
resp.data[1].tag,
vec!["network".to_string(), "mainnet".to_string()]
);
assert_eq!(resp.data[2].tag, vec!["p95".to_string()]);
}

#[tokio::test]
Expand Down Expand Up @@ -2932,6 +2976,29 @@ mod tests {
assert!(resp.data.unwrap().success.unwrap());
}

// Wire-inspection regression: confirm an empty endpoint_ids array reaches
// the wire as `[]` (not omitted), so any future `skip_serializing_if`
// change that drops the empty case fails loudly.
#[tokio::test]
async fn update_team_endpoints_empty_array_wire_body() {
use wiremock::matchers::body_json;
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/teams/1/endpoints"))
.and(body_json(serde_json::json!({ "endpoint_ids": [] })))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": {"success": true},
"error": null
})))
.mount(&server)
.await;
let sdk = make_sdk(format!("{}/", server.uri()));
let params = UpdateTeamEndpointsRequest {
endpoint_ids: vec![],
};
sdk.admin.update_team_endpoints(1, &params).await.unwrap();
}

#[tokio::test]
async fn invite_team_member_success() {
let server = MockServer::start().await;
Expand Down
86 changes: 85 additions & 1 deletion crates/core/src/kvstore/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -681,7 +681,7 @@ impl KvStoreApiClient {
mod tests {
use super::*;
use crate::{KvStoreConfig, QuicknodeSdk, SdkFullConfig};
use wiremock::matchers::{method, path};
use wiremock::matchers::{body_json, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

fn make_sdk(base_url: String) -> QuicknodeSdk {
Expand Down Expand Up @@ -938,6 +938,59 @@ mod tests {
assert!(matches!(err, SdkError::Api { .. }));
}

// Wire-inspection regressions: confirm that addSets and deleteSets reach
// the wire under the names the API expects, so any future serde rename of
// these fields fails loudly.
#[tokio::test]
async fn bulk_sets_wire_body_add_sets() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/sets/bulk"))
.and(body_json(serde_json::json!({
"add_sets": {"k1": "v1"}
})))
.respond_with(
ResponseTemplate::new(201)
.set_body_json(serde_json::json!({"code": 0, "msg": "ok", "data": null})),
)
.mount(&server)
.await;
let sdk = make_sdk(format!("{}/", server.uri()));
let mut add = HashMap::new();
add.insert("k1".to_string(), "v1".to_string());
sdk.kvstore
.bulk_sets(&BulkSetsParams {
add_sets: Some(add),
delete_sets: None,
})
.await
.unwrap();
}

#[tokio::test]
async fn bulk_sets_wire_body_delete_sets() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/sets/bulk"))
.and(body_json(serde_json::json!({
"delete_sets": ["k1", "k2"]
})))
.respond_with(
ResponseTemplate::new(201)
.set_body_json(serde_json::json!({"code": 0, "msg": "ok", "data": null})),
)
.mount(&server)
.await;
let sdk = make_sdk(format!("{}/", server.uri()));
sdk.kvstore
.bulk_sets(&BulkSetsParams {
add_sets: None,
delete_sets: Some(vec!["k1".to_string(), "k2".to_string()]),
})
.await
.unwrap();
}

#[tokio::test]
async fn delete_set_success() {
let server = MockServer::start().await;
Expand Down Expand Up @@ -1192,6 +1245,37 @@ mod tests {
.unwrap();
}

// Wire-inspection regression: confirm addItems/removeItems reach the wire
// under the names the API expects, so any future serde rename of these
// fields fails loudly.
#[tokio::test]
async fn update_list_wire_body() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/lists/my-list"))
.and(body_json(serde_json::json!({
"add_items": ["c"],
"remove_items": ["a"]
})))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"code": 0, "msg": "ok", "data": null})),
)
.mount(&server)
.await;
let sdk = make_sdk(format!("{}/", server.uri()));
sdk.kvstore
.update_list(
"my-list",
&UpdateListParams {
add_items: Some(vec!["c".to_string()]),
remove_items: Some(vec!["a".to_string()]),
},
)
.await
.unwrap();
}

#[tokio::test]
async fn update_list_api_error() {
let server = MockServer::start().await;
Expand Down
4 changes: 2 additions & 2 deletions crates/core/src/streams/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -944,7 +944,7 @@ mod tests {
network: "ethereum-mainnet".to_string(),
dataset: StreamDataset::Block,
block: "17811625".to_string(),
filter_function: None,
filter_function: "ZnVuY3Rpb24gbWFpbihkYXRhKSB7IHJldHVybiBkYXRhOyB9".to_string(),
filter_language: None,
address_book_config: None,
};
Expand All @@ -965,7 +965,7 @@ mod tests {
network: "ethereum-mainnet".to_string(),
dataset: StreamDataset::Block,
block: "17811625".to_string(),
filter_function: None,
filter_function: "ZnVuY3Rpb24gbWFpbihkYXRhKSB7IHJldHVybiBkYXRhOyB9".to_string(),
filter_language: None,
address_book_config: None,
};
Expand Down
Loading
Loading