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
2 changes: 2 additions & 0 deletions crates/clickhouse-cloud-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ The beta BYOC infrastructure methods `organization_byoc_infrastructure_get` (ret

The beta `organization_byoc_infrastructure_private_endpoint_config_get` method returns a BYOC infrastructure's private link endpoint service as `ByocInfrastructurePrivateEndpointConfig` (`endpoint_name`, `private_dns_hostname`); for BYOC organizations it replaces the deprecated `organization_private_endpoint_config_get_list`. The beta `organization_byoc_infrastructure_tags_get` method returns the infrastructure's current additional tags as `ByocInfrastructureTagsResponse`. `ByocInfrastructurePatchRequest` gains optional `enable_private_link`, `enable_private_load_balancer`, `enable_public_load_balancer`, `gcp_psc_subnet_id` (GCP BYO-VPC only, accepted only with `enable_private_link: Some(true)`), and `tags`, which replaces the tag set as a whole. Struct-literal callers of `ByocInfrastructurePatchRequest` need the new fields as `None` or `..Default::default()`.

The beta `notification_get_list` method returns one page as `ApiResponse<Vec<Notification>>`, including envelope `limit` and `next_cursor`. Pass the opaque cursor unchanged to retrieve the next page, stopping when it is absent. Optional filters cover severity, notification type, resource ID/kind, and inclusive RFC 3339 date bounds. The server defaults the page size to 100 and clamps it to 250; notifications are retained for 180 days and restricted to resources the caller can access. All `Notification` fields tolerate absence and null; type, severity and resource kind stay open strings.

The beta `notification_types_get_list` method returns the organization's notification types as `NotificationType` entries (`name`, `title`, `description`, `category`, `severity`, `resource_type`, `is_configurable`, `is_available`); `severity` is `NotificationTypeSeverity` (`Info`, `Warning`, `Critical`, or `Unknown(String)`), while `category` and `resource_type` are open-ended strings. Types the caller lacks permission to use are returned with `is_available: Some(false)`.

The beta `replica_aware_routing_get` and `replica_aware_routing_update` service methods read and change replica aware (sticky) routing as `ReplicaAwareRouting` (`http_enabled`, `native_enabled`). Update takes `ReplicaAwareRoutingPatchRequest`; set at least one field, and an omitted field is left unchanged. Enabling HTTP routing requires clients to send the `X-ClickHouse-Replica-Tag` header.
Expand Down
59 changes: 59 additions & 0 deletions crates/clickhouse-cloud-api/src/client/organizations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,65 @@ impl Client {
Ok(serde_json::from_str(&body_text)?)
}

/// Get one page of organization notifications, most recent first (Beta).
///
/// Pass `next_cursor` unchanged as `cursor` until it is absent. The server
/// defaults `limit` to 100 and clamps values above 250. Date filters are
/// RFC 3339 timestamps. Results are restricted to resources the caller can access.
#[allow(clippy::too_many_arguments)]
pub async fn notification_get_list(
&self,
organization_id: &str,
limit: Option<i64>,
cursor: Option<&str>,
severity: Option<&str>,
r#type: Option<&str>,
resource_id: Option<&str>,
resource_type: Option<&str>,
from_date: Option<&str>,
to_date: Option<&str>,
) -> Result<ApiResponse<Vec<Notification>>, Error> {
let path = format!("/v1/organizations/{organization_id}/notifications");
let mut req = self.request(reqwest::Method::GET, &path);
if let Some(value) = limit {
req = req.query(&[("limit", value)]);
}
if let Some(value) = cursor {
req = req.query(&[("cursor", value)]);
}
if let Some(value) = severity {
req = req.query(&[("severity", value)]);
}
if let Some(value) = r#type {
req = req.query(&[("type", value)]);
}
if let Some(value) = resource_id {
req = req.query(&[("resource_id", value)]);
}
if let Some(value) = resource_type {
req = req.query(&[("resource_type", value)]);
}
if let Some(value) = from_date {
req = req.query(&[("from_date", value)]);
}
if let Some(value) = to_date {
req = req.query(&[("to_date", value)]);
}
let resp = req.send().await?;
let status = resp.status();
let body_text = resp.text().await?;
if !status.is_success() {
return Err(Error::Api {
status: status.as_u16(),
message: serde_json::from_str::<ApiResponse<serde_json::Value>>(&body_text)
.ok()
.and_then(|r| r.error)
.unwrap_or(body_text.clone()),
});
}
Ok(serde_json::from_str(&body_text)?)
}

/// List notification types
///
/// Returns every notification type the organization is entitled to; types
Expand Down
2 changes: 1 addition & 1 deletion crates/clickhouse-cloud-api/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ pub use organization_private_endpoints::{
OrganizationPrivateEndpointRegion, OrganizationPrivateEndpointsPatch,
};
pub use organizations::{
ActiveBalance, ActiveBalances, CreditBalance, CreditBalanceType, CreditBalances,
ActiveBalance, ActiveBalances, CreditBalance, CreditBalanceType, CreditBalances, Notification,
NotificationType, NotificationTypeSeverity, Organization, OrganizationCapabilities,
OrganizationPatchRequest, PrometheusDiscoveryLabels, PrometheusDiscoveryTargetGroup,
};
Expand Down
25 changes: 25 additions & 0 deletions crates/clickhouse-cloud-api/src/models/organizations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,31 @@ impl std::fmt::Display for NotificationTypeSeverity {
}
}

/// A retained organization notification (Beta).
///
/// Notification types, severities and resource kinds remain open strings.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct Notification {
#[serde(rename = "id", skip_serializing_if = "Option::is_none")]
pub id: Option<uuid::Uuid>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub r#type: Option<String>,
#[serde(rename = "severity", skip_serializing_if = "Option::is_none")]
pub severity: Option<String>,
#[serde(rename = "resourceType", skip_serializing_if = "Option::is_none")]
pub resource_type: Option<String>,
#[serde(rename = "resourceId", skip_serializing_if = "Option::is_none")]
pub resource_id: Option<String>,
#[serde(rename = "resourceName", skip_serializing_if = "Option::is_none")]
pub resource_name: Option<String>,
#[serde(rename = "createdAt", skip_serializing_if = "Option::is_none")]
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(rename = "title", skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(rename = "description", skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}

/// `NotificationType` from the ClickHouse Cloud API.
///
/// A notification type the organization is entitled to. `category` and
Expand Down
116 changes: 116 additions & 0 deletions crates/clickhouse-cloud-api/tests/client_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6775,3 +6775,119 @@ async fn replica_aware_routing_update_surfaces_api_errors() {
other => panic!("unexpected error: {other:?}"),
}
}

#[tokio::test]
async fn notification_get_list_preserves_filters_cursor_and_envelope() {
let (server, client) = setup().await;
let cursor = "opaque +/=&?é";
Mock::given(method("GET"))
.and(path("/v1/organizations/org-1/notifications"))
.and(basic_auth("key", "secret"))
.and(query_param("limit", "25"))
.and(query_param("cursor", cursor))
.and(query_param("severity", "warning"))
.and(query_param("type", "service_backup_failed"))
.and(query_param("resource_id", "service-1"))
.and(query_param("resource_type", "service"))
.and(query_param("from_date", "2026-10-01T00:00:00+02:00"))
.and(query_param("to_date", "2026-10-08T00:00:00Z"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"status": 200, "requestId": "notification-page", "limit": 25, "nextCursor": "",
"result": [{
"id": "1b7e0a94-2d0f-4c3a-8f1e-6c9b2d5a4e7f",
"type": "service_backup_failed", "severity": "warning", "resourceType": "service",
"resourceId": "service-1", "resourceName": "analytics", "createdAt": "2026-10-07T09:30:00Z",
"title": "Backup failed", "description": "Review backup settings.", "future": 42
}]
})))
.expect(1)
.mount(&server)
.await;
let response = client
.notification_get_list(
"org-1",
Some(25),
Some(cursor),
Some("warning"),
Some("service_backup_failed"),
Some("service-1"),
Some("service"),
Some("2026-10-01T00:00:00+02:00"),
Some("2026-10-08T00:00:00Z"),
)
.await
.unwrap();
assert_eq!(response.request_id.as_deref(), Some("notification-page"));
assert_eq!(response.limit, Some(25));
assert_eq!(response.next_cursor.as_deref(), Some(""));
let notifications = response.result.unwrap();
assert_eq!(notifications.len(), 1);
assert_eq!(notifications[0].title.as_deref(), Some("Backup failed"));
assert_eq!(notifications[0].resource_name.as_deref(), Some("analytics"));
assert!(notifications[0].created_at.is_some());
}

#[tokio::test]
async fn notification_get_list_tolerates_sparse_null_and_empty_pages() {
for body in [
serde_json::json!({"result": [{}, {"id": null, "createdAt": null}], "nextCursor": null}),
serde_json::json!({"result": []}),
serde_json::json!({"result": null}),
serde_json::json!({}),
] {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/organizations/org-1/notifications"))
.and(bearer_token("token"))
.respond_with(ResponseTemplate::new(200).set_body_json(body.clone()))
.expect(1)
.mount(&server)
.await;
let client = Client::with_bearer_token(server.uri(), "token");
let response = client
.notification_get_list("org-1", None, None, None, None, None, None, None, None)
.await
.unwrap();
assert_eq!(response.next_cursor, None);
if body["result"].is_array() {
assert_eq!(
response.result.as_ref().unwrap().len(),
body["result"].as_array().unwrap().len()
);
} else {
assert_eq!(response.result, None);
}
let requests = server.received_requests().await.unwrap();
assert_eq!(requests[0].url.query(), None);
}
}

#[tokio::test]
async fn notification_get_list_surfaces_api_errors() {
let (server, client) = setup().await;
Mock::given(method("GET"))
.and(path("/v1/organizations/org-1/notifications"))
.respond_with(
ResponseTemplate::new(400)
.set_body_json(serde_json::json!({"error": "invalid cursor"})),
)
.mount(&server)
.await;
let error = client
.notification_get_list(
"org-1",
None,
Some("bad"),
None,
None,
None,
None,
None,
None,
)
.await
.unwrap_err();
assert!(
matches!(error, clickhouse_cloud_api::Error::Api { status: 400, message } if message == "invalid cursor")
);
}
24 changes: 24 additions & 0 deletions crates/clickhouse-cloud-api/tests/integration_org_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,30 @@ async fn cloud_org_lifecycle() -> TestResult<()> {
})
.await?;

// This read-only step permits an empty notification history.
failures
.run(&ctx, StepKind::NonBlocking, "list notifications", || {
let client = client.clone();
let org_id = ctx.org_id.clone();
async move {
let page = client.notification_get_list(
&org_id, Some(1), None, None, None, None, None, None, None,
).await?;
let notifications = page.result.ok_or("notifications returned no result")?;
if notifications.len() > 1 {
return Err("notification page exceeded requested limit".into());
}
if let Some(cursor) = page.next_cursor {
let next = client.notification_get_list(
&org_id, Some(1), Some(&cursor), None, None, None, None, None, None,
).await?;
next.result.ok_or("next notification page returned no result")?;
}
Ok(())
}
})
.await?;

// ── Members ─────────────────────────────────────────────────
//
// Cover `member_get_list`, `member_get`, and `member_update`
Expand Down
8 changes: 8 additions & 0 deletions crates/clickhouse-cloud-api/tests/model_facade_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,3 +259,11 @@ fn extracted_models_keep_root_and_models_paths() {
);
assert_same_type(api::Udf::default(), api::models::Udf::default());
}

#[test]
fn notification_is_available_through_both_public_paths() {
assert_same_type(
api::Notification::default(),
api::models::Notification::default(),
);
}
21 changes: 21 additions & 0 deletions crates/clickhouse-cloud-api/tests/models_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9018,3 +9018,24 @@ fn bigquery_replication_modes_cover_cdc_and_preserve_future_values() {
&["snapshot", "cdc", "cdc_only"]
);
}

#[test]
fn notification_tolerates_missing_null_and_future_values() {
let missing: Notification = serde_json::from_str("{}").unwrap();
assert_eq!(missing, Notification::default());
let null: Notification = serde_json::from_value(serde_json::json!({
"id": null, "type": null, "severity": null, "resourceType": null,
"resourceId": null, "resourceName": null, "createdAt": null,
"title": null, "description": null, "future": true
}))
.unwrap();
assert_eq!(null, missing);
assert_eq!(serde_json::to_value(null).unwrap(), serde_json::json!({}));
let future: Notification = serde_json::from_value(serde_json::json!({
"type": "future.type", "severity": "future.severity", "resourceType": "future.resource"
}))
.unwrap();
assert_eq!(future.r#type.as_deref(), Some("future.type"));
assert_eq!(future.severity.as_deref(), Some("future.severity"));
assert_eq!(future.resource_type.as_deref(), Some("future.resource"));
}
Loading