From b84d7b9a7970d80fd6b97bc0ff5d97f840fa3153 Mon Sep 17 00:00:00 2001 From: sdairs Date: Tue, 20 Oct 2026 12:01:37 +0100 Subject: [PATCH] fix(api): decode Query API endpoint list envelopes Read list entries and cursor metadata from the actual Cloud API envelope. Preserve CLI list output and name selection, and correct the client, model, subprocess and lifecycle fixtures. --- README.md | 2 +- crates/clickhouse-cloud-api/README.md | 4 +- .../src/client/query_api_endpoints.rs | 4 +- .../src/models/query_api_endpoints.rs | 7 +- .../clickhouse-cloud-api/tests/client_test.rs | 29 ++-- .../tests/integration_test.rs | 16 +-- .../clickhouse-cloud-api/tests/models_test.rs | 28 ++++ .../src/cloud/query_api_endpoints.rs | 15 +- .../tests/cli_request_shape_test.rs | 135 ++++++++++-------- docs/reference/query-api.md | 2 +- 10 files changed, 152 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 177c29c8..b01aebe2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/clickhouse-cloud-api/README.md b/crates/clickhouse-cloud-api/README.md index fba6c6bc..d1bd5818 100644 --- a/crates/clickhouse-cloud-api/README.md +++ b/crates/clickhouse-cloud-api/README.md @@ -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>`. 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. diff --git a/crates/clickhouse-cloud-api/src/client/query_api_endpoints.rs b/crates/clickhouse-cloud-api/src/client/query_api_endpoints.rs index 83c6f56a..4bd25c27 100644 --- a/crates/clickhouse-cloud-api/src/client/query_api_endpoints.rs +++ b/crates/clickhouse-cloud-api/src/client/query_api_endpoints.rs @@ -57,7 +57,7 @@ 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, @@ -65,7 +65,7 @@ impl Client { service_id: &str, cursor: Option<&str>, limit: Option, - ) -> Result, Error> { + ) -> Result>, Error> { let path = format!( "/v1/organizations/{organization_id}/services/{service_id}/query-api-endpoints" ); diff --git a/crates/clickhouse-cloud-api/src/models/query_api_endpoints.rs b/crates/clickhouse-cloud-api/src/models/query_api_endpoints.rs index df378175..0e727475 100644 --- a/crates/clickhouse-cloud-api/src/models/query_api_endpoints.rs +++ b/crates/clickhouse-cloud-api/src/models/query_api_endpoints.rs @@ -118,10 +118,11 @@ pub struct PublicQueryApiEndpointRequest { pub allowed_origins: Option>, } -/// `QueryApiEndpointListResponse` from the ClickHouse Cloud API. +/// Legacy nested page container retained for existing callers. /// -/// Used in response position only: every field is `Option`, 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>`] 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")] diff --git a/crates/clickhouse-cloud-api/tests/client_test.rs b/crates/clickhouse-cloud-api/tests/client_test.rs index 73d5f683..5a05c2af 100644 --- a/crates/clickhouse-cloud-api/tests/client_test.rs +++ b/crates/clickhouse-cloud-api/tests/client_test.rs @@ -5869,16 +5869,18 @@ 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; @@ -5886,6 +5888,9 @@ async fn query_api_endpoint_list_encodes_cursor_and_omits_absent_parameters() { .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 @@ -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; diff --git a/crates/clickhouse-cloud-api/tests/integration_test.rs b/crates/clickhouse-cloud-api/tests/integration_test.rs index c93dd3e6..18a46f8b 100644 --- a/crates/clickhouse-cloud-api/tests/integration_test.rs +++ b/crates/clickhouse-cloud-api/tests/integration_test.rs @@ -38,19 +38,13 @@ 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) { @@ -58,9 +52,9 @@ async fn list_saved_query_endpoints( } 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); diff --git a/crates/clickhouse-cloud-api/tests/models_test.rs b/crates/clickhouse-cloud-api/tests/models_test.rs index 551a2153..9e748ce5 100644 --- a/crates/clickhouse-cloud-api/tests/models_test.rs +++ b/crates/clickhouse-cloud-api/tests/models_test.rs @@ -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> = + 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> = + 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!({ diff --git a/crates/clickhousectl/src/cloud/query_api_endpoints.rs b/crates/clickhousectl/src/cloud/query_api_endpoints.rs index e137bc01..60718adb 100644 --- a/crates/clickhousectl/src/cloud/query_api_endpoints.rs +++ b/crates/clickhousectl/src/cloud/query_api_endpoints.rs @@ -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; @@ -368,12 +368,21 @@ impl CloudClient { cursor: Option<&str>, limit: Option, ) -> CloudResult { - 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, diff --git a/crates/clickhousectl/tests/cli_request_shape_test.rs b/crates/clickhousectl/tests/cli_request_shape_test.rs index c618a2b8..47a680a7 100644 --- a/crates/clickhousectl/tests/cli_request_shape_test.rs +++ b/crates/clickhousectl/tests/cli_request_shape_test.rs @@ -26651,6 +26651,22 @@ fn query_api_endpoint_envelope(status: u16, result: Value) -> ResponseTemplate { })) } +fn query_api_endpoint_list_page( + items: Value, + next_cursor: Option<&str>, + total_count: Option, + limit: Option, +) -> ResponseTemplate { + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "status": 200, + "requestId": "query-api-endpoint-request", + "result": items, + "nextCursor": next_cursor, + "totalCount": total_count, + "limit": limit + })) +} + #[tokio::test] async fn query_api_endpoint_all_verbs_use_exact_routes_auth_and_complete_bodies() { for file_flag in ["--file", "--config-file"] { @@ -26677,7 +26693,7 @@ async fn query_api_endpoint_all_verbs_use_exact_routes_auth_and_complete_bodies_ "url": format!("https://queries.clickhouse.cloud/run/{QUERY_API_ENDPOINT_ID}"), "ownerType": "queryApiEndpoint" }], - "pagination": {"currentCursor": "current", "limit": 100, "totalRecords": 1} + "pagination": {"limit": 100, "totalRecords": 1} }); for (verb, suffix, body, status, result) in [ ( @@ -26712,10 +26728,12 @@ async fn query_api_endpoint_all_verbs_use_exact_routes_auth_and_complete_bodies_ if let Some(body) = body { mock = mock.and(body_json(body)); } - mock.respond_with(query_api_endpoint_envelope(status, result)) - .expect(1) - .mount(&server) - .await; + let response = if verb == "GET" && suffix.is_empty() { + query_api_endpoint_list_page(result["items"].clone(), None, Some(1), Some(100)) + } else { + query_api_endpoint_envelope(status, result) + }; + mock.respond_with(response).expect(1).mount(&server).await; } Mock::given(method("DELETE")) .and(path(format!( @@ -26860,7 +26878,12 @@ async fn query_api_endpoint_reads_support_oauth_and_preserve_reserved_list_curso Mock::given(method("GET")) .and(path(QUERY_API_ENDPOINTS_PATH)) .and(header("authorization", "Bearer test-bearer-token")) - .respond_with(query_api_endpoint_envelope(200, result.clone())) + .respond_with(query_api_endpoint_list_page( + result["items"].clone(), + None, + Some(3), + Some(2), + )) .expect(1) .mount(&server) .await; @@ -26933,21 +26956,20 @@ async fn query_api_endpoint_human_reads_tolerate_sparse_fields_and_show_next_cur .await; Mock::given(method("GET")) .and(path(QUERY_API_ENDPOINTS_PATH)) - .respond_with(query_api_endpoint_envelope( - 200, - serde_json::json!({ - "items": [ - {}, - { - "id": QUERY_API_ENDPOINT_ID, - "name": "orders", - "database": "analytics", - "ownerType": "futureOwner", - "url": "https://queries.clickhouse.cloud/run/example" - } - ], - "pagination": {"nextCursor": "next /+?"} - }), + .respond_with(query_api_endpoint_list_page( + serde_json::json!([ + {}, + { + "id": QUERY_API_ENDPOINT_ID, + "name": "orders", + "database": "analytics", + "ownerType": "futureOwner", + "url": "https://queries.clickhouse.cloud/run/example" + } + ]), + Some("next /+?"), + None, + None, )) .expect(1) .mount(&server) @@ -26977,11 +26999,11 @@ async fn query_api_endpoint_human_reads_tolerate_sparse_fields_and_show_next_cur assert!(stdout.contains(value), "missing {value}:\n{stdout}"); } - for result in [serde_json::json!({}), serde_json::json!({"items": null})] { + for response in [serde_json::json!({}), serde_json::json!({"result": null})] { let sparse_server = MockServer::start().await; Mock::given(method("GET")) .and(path(QUERY_API_ENDPOINTS_PATH)) - .respond_with(query_api_endpoint_envelope(200, result)) + .respond_with(ResponseTemplate::new(200).set_body_json(response)) .expect(1) .mount(&sparse_server) .await; @@ -26994,22 +27016,18 @@ async fn query_api_endpoint_human_reads_tolerate_sparse_fields_and_show_next_cur &["list", "svc-1", "--org-id", "org-1"], None, ); - assert_success(&output); - assert_eq!( - String::from_utf8_lossy(&output.stdout), - "Query API endpoints: -\n" - ); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); } let empty_server = MockServer::start().await; Mock::given(method("GET")) .and(path(QUERY_API_ENDPOINTS_PATH)) - .respond_with(query_api_endpoint_envelope( - 200, - serde_json::json!({ - "items": [], - "pagination": {"nextCursor": "after-empty-page"} - }), + .respond_with(query_api_endpoint_list_page( + serde_json::json!([]), + Some("after-empty-page"), + None, + None, )) .expect(1) .mount(&empty_server) @@ -28043,25 +28061,32 @@ async fn selectors_query_endpoints_read_all_cursor_pages_before_mutating() { let first_id = "11111111-2222-3333-4444-555555555555"; let second_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; for second in [ - serde_json::json!({"items": [], "pagination": {"nextCursor": null}}), - serde_json::json!({"items": [{"id": second_id, "name": "target"}], "pagination": {"nextCursor": null}}), - serde_json::json!({"items": []}), - serde_json::json!({"items": [], "pagination": {"nextCursor": "page-2"}}), + serde_json::json!({"result": [], "nextCursor": null}), + serde_json::json!({"result": [{"id": second_id, "name": "target"}], "nextCursor": null}), + serde_json::json!({"result": null, "nextCursor": null}), + serde_json::json!({"result": [], "nextCursor": "page-2"}), ] { - let unique = second["pagination"].is_object() - && second["pagination"]["nextCursor"].is_null() - && second["items"].as_array().unwrap().is_empty(); + let unique = second["nextCursor"].is_null() + && second["result"].as_array().is_some_and(Vec::is_empty); let mock = MockServer::start().await; let list_path = "/v1/organizations/org-1/services/parent/query-api-endpoints"; - Mock::given(method("GET")).and(path(list_path)).and(query_param("limit", "100")).and(wiremock::matchers::query_param_is_missing("cursor")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"result": {"items": [{"id": first_id, "name": "target"}], "pagination": {"nextCursor": "page-2"}}}))) - .expect(1).mount(&mock).await; + Mock::given(method("GET")) + .and(path(list_path)) + .and(query_param("limit", "100")) + .and(wiremock::matchers::query_param_is_missing("cursor")) + .respond_with(query_api_endpoint_list_page( + serde_json::json!([{"id": first_id, "name": "target"}]), + Some("page-2"), + None, + Some(100), + )) + .expect(1) + .mount(&mock) + .await; Mock::given(method("GET")) .and(path(list_path)) .and(query_param("cursor", "page-2")) - .respond_with( - ResponseTemplate::new(200).set_body_json(serde_json::json!({"result": second})), - ) + .respond_with(ResponseTemplate::new(200).set_body_json(second)) .expect(1) .mount(&mock) .await; @@ -28386,17 +28411,17 @@ async fn selectors_endpoint_pagination_integrity_and_later_errors_block_mutation for (status, second, exit) in [ ( 200, - serde_json::json!({"items": [{"id": other_id, "name": "other"}], "pagination": {"totalRecords": 3, "nextCursor": null}}), + serde_json::json!({"result": [{"id": other_id, "name": "other"}], "totalCount": 3, "nextCursor": null}), 1, ), ( 200, - serde_json::json!({"items": [], "pagination": {"totalRecords": 4, "nextCursor": null}}), + serde_json::json!({"result": [], "totalCount": 4, "nextCursor": null}), 1, ), ( 200, - serde_json::json!({"items": [], "pagination": {"totalRecords": 3, "nextCursor": null}}), + serde_json::json!({"result": [], "totalCount": 3, "nextCursor": null}), 1, ), (403, serde_json::json!({"error": "later page forbidden"}), 4), @@ -28404,18 +28429,12 @@ async fn selectors_endpoint_pagination_integrity_and_later_errors_block_mutation let mock = MockServer::start().await; let list_path = "/v1/organizations/org-1/services/parent/query-api-endpoints"; Mock::given(method("GET")).and(path(list_path)).and(wiremock::matchers::query_param_is_missing("cursor")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"result": {"items": [{"id": target_id, "name": "target"}, {"id": other_id, "name": "other"}], "pagination": {"totalRecords": 3, "nextCursor": "page-2"}}}))) + .respond_with(query_api_endpoint_list_page(serde_json::json!([{"id": target_id, "name": "target"}, {"id": other_id, "name": "other"}]), Some("page-2"), Some(3), Some(100))) .expect(1).mount(&mock).await; Mock::given(method("GET")) .and(path(list_path)) .and(query_param("cursor", "page-2")) - .respond_with( - ResponseTemplate::new(status).set_body_json(if status == 200 { - serde_json::json!({"result": second}) - } else { - second - }), - ) + .respond_with(ResponseTemplate::new(status).set_body_json(second)) .expect(1) .mount(&mock) .await; diff --git a/docs/reference/query-api.md b/docs/reference/query-api.md index cc1c8236..6820448b 100644 --- a/docs/reference/query-api.md +++ b/docs/reference/query-api.md @@ -101,7 +101,7 @@ clickhousectl cloud query-api-endpoint create --file endpoint.json clickhousectl cloud query-api-endpoint get ``` -Update replaces the complete definition. Omitted parameters/origins become empty; GET output has response-only fields and cannot be resubmitted unchanged. User-owned endpoints can be read but not updated/deleted through this command. List returns one page; continue with `pagination.nextCursor` and `--cursor` (`--limit` is 1–100). +Update replaces the complete definition. Omitted parameters/origins become empty; GET output has response-only fields and cannot be resubmitted unchanged. User-owned endpoints can be read but not updated/deleted through this command. List JSON contains `items` and `pagination`; continue with `pagination.nextCursor` and `--cursor` (`--limit` is 1–100). `pagination.currentCursor` reflects the supplied cursor; `pagination.totalRecords` reflects the API's optional total count. Call the returned `url` using the bound key's authentication `keyId` and `keySecret`, not its management ID. Supply every SQL placeholder explicitly: stored parameter defaults were not applied during live endpoint validation.