diff --git a/crates/clickhouse-cloud-api/README.md b/crates/clickhouse-cloud-api/README.md index c8c2a656..3878e42a 100644 --- a/crates/clickhouse-cloud-api/README.md +++ b/crates/clickhouse-cloud-api/README.md @@ -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>`, 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. diff --git a/crates/clickhouse-cloud-api/src/client/organizations.rs b/crates/clickhouse-cloud-api/src/client/organizations.rs index 9e705628..2405a55b 100644 --- a/crates/clickhouse-cloud-api/src/client/organizations.rs +++ b/crates/clickhouse-cloud-api/src/client/organizations.rs @@ -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, + 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>, 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::>(&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 diff --git a/crates/clickhouse-cloud-api/src/models.rs b/crates/clickhouse-cloud-api/src/models.rs index 2646f73c..7beb421c 100644 --- a/crates/clickhouse-cloud-api/src/models.rs +++ b/crates/clickhouse-cloud-api/src/models.rs @@ -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, }; diff --git a/crates/clickhouse-cloud-api/src/models/organizations.rs b/crates/clickhouse-cloud-api/src/models/organizations.rs index 863c730a..ba0c127d 100644 --- a/crates/clickhouse-cloud-api/src/models/organizations.rs +++ b/crates/clickhouse-cloud-api/src/models/organizations.rs @@ -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, + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub r#type: Option, + #[serde(rename = "severity", skip_serializing_if = "Option::is_none")] + pub severity: Option, + #[serde(rename = "resourceType", skip_serializing_if = "Option::is_none")] + pub resource_type: Option, + #[serde(rename = "resourceId", skip_serializing_if = "Option::is_none")] + pub resource_id: Option, + #[serde(rename = "resourceName", skip_serializing_if = "Option::is_none")] + pub resource_name: Option, + #[serde(rename = "createdAt", skip_serializing_if = "Option::is_none")] + pub created_at: Option>, + #[serde(rename = "title", skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(rename = "description", skip_serializing_if = "Option::is_none")] + pub description: Option, +} + /// `NotificationType` from the ClickHouse Cloud API. /// /// A notification type the organization is entitled to. `category` and diff --git a/crates/clickhouse-cloud-api/tests/client_test.rs b/crates/clickhouse-cloud-api/tests/client_test.rs index 63491634..3fdc893e 100644 --- a/crates/clickhouse-cloud-api/tests/client_test.rs +++ b/crates/clickhouse-cloud-api/tests/client_test.rs @@ -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") + ); +} diff --git a/crates/clickhouse-cloud-api/tests/integration_org_test.rs b/crates/clickhouse-cloud-api/tests/integration_org_test.rs index 38b30931..e70d8f04 100644 --- a/crates/clickhouse-cloud-api/tests/integration_org_test.rs +++ b/crates/clickhouse-cloud-api/tests/integration_org_test.rs @@ -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` diff --git a/crates/clickhouse-cloud-api/tests/model_facade_test.rs b/crates/clickhouse-cloud-api/tests/model_facade_test.rs index 5e6c401b..ad766ead 100644 --- a/crates/clickhouse-cloud-api/tests/model_facade_test.rs +++ b/crates/clickhouse-cloud-api/tests/model_facade_test.rs @@ -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(), + ); +} diff --git a/crates/clickhouse-cloud-api/tests/models_test.rs b/crates/clickhouse-cloud-api/tests/models_test.rs index ba4a9eb5..49eaf5e0 100644 --- a/crates/clickhouse-cloud-api/tests/models_test.rs +++ b/crates/clickhouse-cloud-api/tests/models_test.rs @@ -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")); +}