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: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ clickhousectl cloud service create --name analytics \

Creation returns before provisioning finishes. Queries use OAuth for read-only SQL or an API key for permitted reads and writes. On first use, API-key queries can bind the caller's key to the service's Query API endpoint; use `--no-auto-enable` to require an existing binding.

The Query API may time out while SQL continues running. Check the outcome before retrying a write. Use a native client for long queries and bulk loads. See [create and operate a Cloud service](docs/guides/cloud-service.md) and [SQL and Query API behavior](docs/reference/query-api.md).
The Query API may time out while SQL continues running. Check the outcome before retrying a write. Use a native client for long queries and bulk loads. Named endpoint lists return one page; pass `pagination.nextCursor` from JSON output to `query-api-endpoint list --cursor` to continue. See [create and operate a Cloud service](docs/guides/cloud-service.md) and [SQL and Query API behavior](docs/reference/query-api.md).

### Create ClickHouse Managed Postgres

Expand Down
4 changes: 2 additions & 2 deletions crates/clickhouse-cloud-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,9 @@ The beta `replica_aware_routing_get` and `replica_aware_routing_update` service

The MySQL and Postgres ClickPipe source authentication enums (`ClickPipeMutateMySQLSourceAuthentication`, `ClickPipeMySQLSourceAuthentication`, `ClickPipePatchMySQLSourceAuthentication`, `ClickPipeMutatePostgresSourceAuthentication`, `ClickPipePostgresSourceAuthentication`) gain `ServiceAccountWorkloadIdentity` (`SERVICE_ACCOUNT_WORKLOAD_IDENTITY`), and the MySQL source type enums gain `Cloudsqlmysql` (`cloudsqlmysql`); exhaustive `match`es over them need the new arm. `ClickPipeMongoDBPipeTableMapping` gains `structured_ingestion` (`ClickPipeMongoDBStructuredIngestion`: required `enabled`, plus optional `columns` of `ClickPipeMongoDBPipeColumn` `name`/`type` pairs, `drop_unexpected_values`, `excluded_columns`, `sorting_keys`, and `partition_by_expr`) to replicate a collection into typed columns instead of one JSON column; responses use the all-`Option` `ClickPipeMongoDBStructuredIngestionResponse` and `ClickPipeMongoDBPipeColumnResponse`. Struct-literal callers of the table mapping need `structured_ingestion: None` or `..Default::default()`.

The Query API endpoint management methods are `query_api_endpoint_create`, `query_api_endpoint_get`, `query_api_endpoint_list`, `query_api_endpoint_update`, and `query_api_endpoint_delete`. Create and update take `PublicQueryApiEndpointRequest`; list accepts an optional cursor and limit (1–100) and returns `items` with `pagination.next_cursor`. User-owned endpoints can be listed and read, but cannot be updated or deleted through this API.
The Query API endpoint management methods are `query_api_endpoint_create`, `query_api_endpoint_get`, `query_api_endpoint_list`, `query_api_endpoint_update`, and `query_api_endpoint_delete`. Create and update take `PublicQueryApiEndpointRequest`; list accepts an optional cursor and limit (1–100) and returns `ApiResponse<Vec<PublicQueryApiEndpointListItem>>`. Read entries from `result` and pagination from the envelope's `next_cursor`, `limit`, and `total_count`. The earlier `QueryApiEndpointListResponse` container remains available for existing callers, but is no longer the list method's return payload. User-owned endpoints can be listed and read, but cannot be updated or deleted through this API.

The saved query methods (no longer beta) are `saved_query_create`, `saved_query_get`, `saved_query_list`, `saved_query_update`, and `saved_query_delete`, scoped to a service. Create and update take `PublicSavedQueryRequest` (update replaces the whole query); get returns `PublicSavedQuery` and list returns `PublicSavedQueryListItem` entries. List accepts an optional cursor and limit (1–100) and, unlike the Query API endpoint list, reports pagination on the envelope: `ApiResponse::next_cursor` (`None` on the last page), `limit`, and `total_count`.
The saved query methods (no longer beta) are `saved_query_create`, `saved_query_get`, `saved_query_list`, `saved_query_update`, and `saved_query_delete`, scoped to a service. Create and update take `PublicSavedQueryRequest` (update replaces the whole query); get returns `PublicSavedQuery` and list returns `PublicSavedQueryListItem` entries. List accepts an optional cursor and limit (1–100) and reports pagination on the envelope: `ApiResponse::next_cursor` (`None` on the last page), `limit`, and `total_count`.

The beta `postgres_service_backup_get_list` method lists a Postgres service's retained base backups as `PostgresBackup` entries (`key`, `last_modified`), newest first. It accepts an optional cursor and limit (1–100) and reports pagination on the envelope like `saved_query_list`. Restore a Postgres service to a point in time with `postgres_instance_restore`; backup keys are not restore targets.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,15 @@ impl Client {

/// List Query API endpoints.
///
/// Pass the previous page's `pagination.nextCursor` to retrieve the next page.
/// Pass the previous response's `next_cursor` to retrieve the next page.
/// The API defaults to 100 records per page and accepts limits from 1 to 100.
pub async fn query_api_endpoint_list(
&self,
organization_id: &str,
service_id: &str,
cursor: Option<&str>,
limit: Option<i64>,
) -> Result<ApiResponse<QueryApiEndpointListResponse>, Error> {
) -> Result<ApiResponse<Vec<PublicQueryApiEndpointListItem>>, Error> {
let path = format!(
"/v1/organizations/{organization_id}/services/{service_id}/query-api-endpoints"
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,11 @@ pub struct PublicQueryApiEndpointRequest {
pub allowed_origins: Option<Vec<String>>,
}

/// `QueryApiEndpointListResponse` from the ClickHouse Cloud API.
/// Legacy nested page container retained for existing callers.
///
/// Used in response position only: every field is `Option<T>`, so a field the
/// API drops or sends as `null` deserializes to `None` instead of failing.
/// [`crate::Client::query_api_endpoint_list`] returns an
/// [`super::ApiResponse<Vec<PublicQueryApiEndpointListItem>>`] with pagination
/// on the envelope. This container is not the endpoint's wire response.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct QueryApiEndpointListResponse {
#[serde(rename = "items", skip_serializing_if = "Option::is_none")]
Expand Down
29 changes: 20 additions & 9 deletions crates/clickhouse-cloud-api/tests/client_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5869,23 +5869,28 @@ async fn query_api_endpoint_list_encodes_cursor_and_omits_absent_parameters() {
(Some("next+/=&? page"), Some(25)),
] {
let (server, client) = setup().await;
let result = serde_json::json!({
"items": [{"id": "00000000-0000-4000-8000-000000000003", "name": "example", "ownerType": "user"}],
"pagination": {"nextCursor": "another-page"}
});
let result = serde_json::json!([
{"id": "00000000-0000-4000-8000-000000000003", "name": "example", "ownerType": "user"}
]);
Mock::given(method("GET"))
.and(path(
"/v1/organizations/org-1/services/svc-1/query-api-endpoints",
))
.and(basic_auth("key", "secret"))
.respond_with(ok_json(result.clone()))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"result": result, "limit": 25, "totalCount": 42,
"nextCursor": "another-page", "status": 200
})))
.expect(1)
.mount(&server)
.await;
let response = client
.query_api_endpoint_list("org-1", "svc-1", cursor, limit)
.await
.unwrap();
assert_eq!(response.limit, Some(25));
assert_eq!(response.total_count, Some(42));
assert_eq!(response.next_cursor.as_deref(), Some("another-page"));
assert_eq!(
serde_json::to_value(response.result.unwrap()).unwrap(),
result
Expand All @@ -5912,14 +5917,20 @@ async fn query_api_endpoint_list_encodes_cursor_and_omits_absent_parameters() {
async fn query_api_endpoint_reads_use_bearer_auth() {
let server = MockServer::start().await;
let client = Client::with_bearer_token(server.uri(), "token");
for endpoint_path in [
"/v1/organizations/org/services/svc/query-api-endpoints",
"/v1/organizations/org/services/svc/query-api-endpoints/endpoint",
for (endpoint_path, result) in [
(
"/v1/organizations/org/services/svc/query-api-endpoints",
serde_json::json!([]),
),
(
"/v1/organizations/org/services/svc/query-api-endpoints/endpoint",
serde_json::json!({}),
),
] {
Mock::given(method("GET"))
.and(path(endpoint_path))
.and(bearer_token("token"))
.respond_with(ok_json(serde_json::json!({})))
.respond_with(ok_json(result))
.expect(1)
.mount(&server)
.await;
Expand Down
16 changes: 5 additions & 11 deletions crates/clickhouse-cloud-api/tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,29 +38,23 @@ async fn list_saved_query_endpoints(
for _ in 0..100 {
let page = client
.query_api_endpoint_list(org, service, cursor.as_deref(), Some(1))
.await?
.await?;
let items = page
.result
.ok_or("Query API endpoint list returned no result")?;
let items = page.items.ok_or("Query API endpoint list omitted items")?;
let pagination = page
.pagination
.ok_or("Query API endpoint list omitted pagination")?;
if items.len() > 1 || pagination.limit != Some(1) {
if items.len() > 1 || page.limit != Some(1) {
return Err("Query API endpoint list did not respect limit=1".into());
}
if cursor.is_none() && pagination.current_cursor.is_some() {
return Err("Query API endpoint first page has a non-null current cursor".into());
}
for item in items {
let id = item.id.ok_or("Query API endpoint list item omitted id")?;
if !seen_ids.insert(id) {
return Err("Query API endpoint pagination repeated an endpoint".into());
}
listed.push(item);
}
cursor = pagination.next_cursor;
cursor = page.next_cursor;
let Some(next_cursor) = &cursor else {
if pagination.total_records != Some(listed.len() as i64) {
if page.total_count != Some(listed.len() as i64) {
return Err("Query API endpoint pagination total does not match items".into());
}
return Ok(listed);
Expand Down
28 changes: 28 additions & 0 deletions crates/clickhouse-cloud-api/tests/models_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8068,6 +8068,34 @@ fn query_api_endpoint_responses_tolerate_missing_and_null_fields() {
}
}

#[test]
fn query_api_endpoint_list_response_preserves_array_entries_and_envelope_pagination() {
for items in [serde_json::json!([]), serde_json::json!([{}])] {
let response: ApiResponse<Vec<PublicQueryApiEndpointListItem>> =
serde_json::from_value(serde_json::json!({
"result": items, "limit": 1, "totalCount": 2,
"nextCursor": "opaque +/=&? page", "futureEnvelopeField": true
}))
.unwrap();
assert_eq!(
serde_json::to_value(response.result.unwrap()).unwrap(),
items
);
assert_eq!(response.limit, Some(1));
assert_eq!(response.total_count, Some(2));
assert_eq!(response.next_cursor.as_deref(), Some("opaque +/=&? page"));
}
for wire in [serde_json::json!({}), serde_json::json!({"result": null})] {
let response: ApiResponse<Vec<PublicQueryApiEndpointListItem>> =
serde_json::from_value(wire).unwrap();
assert!(response.result.is_none());
assert_eq!(
serde_json::to_value(response).unwrap(),
serde_json::json!({})
);
}
}

#[test]
fn saved_query_request_is_strict_and_omits_optional_fields() {
let required = serde_json::json!({
Expand Down
15 changes: 12 additions & 3 deletions crates/clickhousectl/src/cloud/query_api_endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ use crate::cloud::shared::{NameSelector, resolve_org_id, select_named_id};
use crate::cloud::types::DeleteResponse;
use clap::{Args, Subcommand};
use clickhouse_cloud_api::models::{
PublicQueryApiEndpoint, PublicQueryApiEndpointRequest, QueryApiEndpointListResponse,
Pagination, PublicQueryApiEndpoint, PublicQueryApiEndpointRequest, QueryApiEndpointListResponse,
};
use serde::Serialize;
use serde_json::Value;
Expand Down Expand Up @@ -368,12 +368,21 @@ impl CloudClient {
cursor: Option<&str>,
limit: Option<i64>,
) -> CloudResult<QueryApiEndpointListResponse> {
let response = self
let mut response = self
.api()
.query_api_endpoint_list(org, service, cursor, limit)
.await
.map_err(|error| self.convert_error_for_organization(error, org))?;
Self::unwrap_response(response)
let pagination = Pagination {
current_cursor: cursor.map(str::to_owned),
limit: response.limit,
next_cursor: response.next_cursor.take(),
total_records: response.total_count,
};
Ok(QueryApiEndpointListResponse {
items: Some(Self::unwrap_response(response)?),
pagination: Some(pagination),
})
}
async fn delete_query_api_endpoint(
&self,
Expand Down
Loading
Loading