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.