Skip to main content

data_gov_ckan/
client.rs

1use crate::models;
2use serde::de::DeserializeOwned;
3use std::sync::Arc;
4
5/// Configuration for the CKAN client
6#[derive(Debug, Clone)]
7pub struct Configuration {
8    /// Base URL for the CKAN API (e.g., `https://catalog.data.gov/api/3`)
9    pub base_path: String,
10    /// User agent string for HTTP requests
11    pub user_agent: Option<String>,
12    /// HTTP client instance
13    pub client: reqwest::Client,
14    /// Basic authentication credentials (username, optional password)
15    pub basic_auth: Option<BasicAuth>,
16    /// OAuth access token
17    pub oauth_access_token: Option<String>,
18    /// Bearer token for authentication
19    pub bearer_access_token: Option<String>,
20    /// API key for CKAN authentication
21    pub api_key: Option<ApiKey>,
22}
23
24/// Basic authentication credentials
25pub type BasicAuth = (String, Option<String>);
26
27/// API key configuration
28#[derive(Debug, Clone)]
29pub struct ApiKey {
30    /// Optional prefix for the API key (e.g., "Bearer")
31    pub prefix: Option<String>,
32    /// The actual API key value
33    pub key: String,
34}
35
36impl Configuration {
37    /// Create a new configuration with default values
38    pub fn new() -> Configuration {
39        Configuration::default()
40    }
41}
42
43impl Default for Configuration {
44    fn default() -> Self {
45        Configuration {
46            base_path: "https://catalog.data.gov/api/3".to_owned(),
47            user_agent: Some(concat!("data-gov-rs/", env!("CARGO_PKG_VERSION")).to_owned()),
48            client: reqwest::Client::new(),
49            basic_auth: None,
50            oauth_access_token: None,
51            bearer_access_token: None,
52            api_key: None,
53        }
54    }
55}
56
57/// Async CKAN client focused on data.gov's read APIs.
58///
59/// `CkanClient` wraps the generated request glue and exposes ergonomic async
60/// methods for popular CKAN endpoints such as `package_search`,
61/// `package_show`, organization listings, and autocomplete helpers. The client
62/// is cheap to clone and share across tasks because it only holds an
63/// [`Arc<Configuration>`].
64///
65/// ```rust,no_run
66/// use data_gov_ckan::{CkanClient, Configuration};
67/// use std::sync::Arc;
68///
69/// # #[tokio::main]
70/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
71/// let client = CkanClient::new(Arc::new(Configuration::default()));
72/// let results = client.package_search(Some("climate"), Some(5), None, None).await?;
73/// println!("{} datasets", results.count.unwrap_or(0));
74/// # Ok(()) }
75/// ```
76pub struct CkanClient {
77    configuration: Arc<Configuration>,
78}
79
80impl std::fmt::Debug for CkanClient {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        f.debug_struct("CkanClient")
83            .field("base_path", &self.configuration.base_path)
84            .finish()
85    }
86}
87
88/// Errors that can occur when interacting with the CKAN API
89///
90/// This enum provides detailed error information for different types of failures
91/// that can occur during CKAN API operations.
92///
93/// # Examples
94///
95/// ```rust
96/// # use data_gov_ckan::{CkanClient, CkanError};
97/// # async fn example() {
98/// match some_api_call().await {
99///     Ok(result) => println!("Success: {:?}", result),
100///     Err(CkanError::RequestError(e)) => {
101///         eprintln!("Network or HTTP error: {}", e);
102///     },
103///     Err(CkanError::ParseError(e)) => {
104///         eprintln!("Failed to parse API response: {}", e);
105///     },
106///     Err(CkanError::ApiError { status, message }) => {
107///         eprintln!("CKAN API returned error {}: {}", status, message);
108///     }
109/// }
110/// # async fn some_api_call() -> Result<(), CkanError> { Ok(()) }
111/// # }
112/// ```
113#[derive(Debug)]
114pub enum CkanError {
115    /// Network, HTTP, or other request-level errors
116    ///
117    /// This includes connection failures, timeouts, DNS resolution issues,
118    /// and HTTP protocol errors (like 500 Internal Server Error).
119    RequestError(Box<dyn std::error::Error + Send + Sync>),
120
121    /// JSON parsing or deserialization errors
122    ///
123    /// Occurs when the CKAN API returns data that doesn't match expected schema,
124    /// invalid JSON, or when response format has changed.
125    ParseError(serde_json::Error),
126
127    /// CKAN-specific API errors with status codes
128    ///
129    /// These are semantic errors from CKAN itself, like:
130    /// - 404: Dataset not found
131    /// - 403: Insufficient permissions
132    /// - 400: Invalid parameters
133    /// - 409: Resource conflicts
134    ApiError {
135        /// HTTP status code from the CKAN API
136        status: u16,
137        /// Human-readable error message from CKAN
138        message: String,
139    },
140}
141
142impl std::fmt::Display for CkanError {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        match self {
145            CkanError::RequestError(e) => write!(f, "Request error: {}", e),
146            CkanError::ParseError(e) => write!(f, "Parse error: {}", e),
147            CkanError::ApiError { status, message } => {
148                write!(f, "CKAN API error ({}): {}", status, message)
149            }
150        }
151    }
152}
153
154impl std::error::Error for CkanError {}
155
156impl CkanClient {
157    /// Create a new CKAN client instance
158    ///
159    /// Creates a client configured to work with a specific CKAN instance.
160    /// For data.gov, use the base URL: `https://catalog.data.gov/api/3`
161    ///
162    /// # Arguments
163    ///
164    /// * `configuration` - API configuration including base URL, user agent, and credentials
165    ///
166    /// # Examples
167    ///
168    /// ```rust
169    /// # use data_gov_ckan::{CkanClient, Configuration, ApiKey};
170    /// # use std::sync::Arc;
171    ///
172    /// // Basic client for read-only operations
173    /// let config = Arc::new(Configuration {
174    ///     base_path: "https://catalog.data.gov/api/3".to_string(),
175    ///     user_agent: Some("my-rust-app/1.0".to_string()),
176    ///     client: reqwest::Client::new(),
177    ///     basic_auth: None,
178    ///     oauth_access_token: None,
179    ///     bearer_access_token: None,
180    ///     api_key: None,
181    /// });
182    ///
183    /// let client = CkanClient::new(config);
184    ///
185    /// // Client with API key for write operations
186    /// let authenticated_config = Arc::new(Configuration {
187    ///     base_path: "https://catalog.data.gov/api/3".to_string(),
188    ///     user_agent: Some("my-rust-app/1.0".to_string()),
189    ///     client: reqwest::Client::new(),
190    ///     basic_auth: None,
191    ///     oauth_access_token: None,
192    ///     bearer_access_token: None,
193    ///     api_key: Some(ApiKey {
194    ///         prefix: None,
195    ///         key: "your-api-key-here".to_string(),
196    ///     }),
197    /// });
198    ///
199    /// let auth_client = CkanClient::new(authenticated_config);
200    /// ```
201    pub fn new(configuration: Arc<Configuration>) -> Self {
202        Self { configuration }
203    }
204
205    /// Call a CKAN action API endpoint and deserialize the result.
206    ///
207    /// All CKAN action endpoints follow the same pattern: GET a URL under
208    /// `/action/<name>` with query parameters, receive a JSON wrapper with
209    /// `{ success: bool, result: ... }`, and extract the `result` field.
210    /// This helper encapsulates that entire flow.
211    async fn call_action<T: DeserializeOwned>(
212        &self,
213        action: &str,
214        params: &[(&str, &str)],
215    ) -> Result<T, CkanError> {
216        let url = format!("{}/action/{}", self.configuration.base_path, action);
217
218        let response = self
219            .configuration
220            .client
221            .get(&url)
222            .query(params)
223            .send()
224            .await
225            .map_err(|e| CkanError::RequestError(Box::new(e)))?;
226
227        if !response.status().is_success() {
228            let status = response.status().as_u16();
229            let error_text = response
230                .text()
231                .await
232                .unwrap_or_else(|_| "Unknown error".to_string());
233            return Err(CkanError::ApiError {
234                status,
235                message: error_text,
236            });
237        }
238
239        let wrapper: models::ActionResponse = response
240            .json()
241            .await
242            .map_err(|e| CkanError::RequestError(Box::new(e)))?;
243
244        if !wrapper.success {
245            return Err(CkanError::ApiError {
246                status: 400,
247                message: "CKAN API reported failure".to_string(),
248            });
249        }
250
251        match wrapper.result {
252            Some(value) => serde_json::from_value(value).map_err(CkanError::ParseError),
253            None => Err(CkanError::ApiError {
254                status: 500,
255                message: "No result data in API response".to_string(),
256            }),
257        }
258    }
259
260    /// Search for datasets (packages) with advanced filtering and faceting
261    ///
262    /// This is the primary method for discovering datasets in the CKAN catalog.
263    /// It provides powerful search capabilities with full-text search, filtering,
264    /// faceting, and pagination.
265    ///
266    /// # Arguments
267    ///
268    /// * `q` - Search query string (searches title, description, tags, etc.)
269    /// * `rows` - Maximum number of results to return (default: 10, max typically: 1000)
270    /// * `start` - Starting offset for pagination (0-based)
271    /// * `fq` - Additional filter queries in Solr format
272    ///
273    /// # Returns
274    ///
275    /// Returns search results with datasets, facets, and pagination information.
276    ///
277    /// # Examples
278    ///
279    /// ```rust
280    /// # use data_gov_ckan::{CkanClient, Configuration};
281    /// # use std::sync::Arc;
282    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
283    /// # let client = CkanClient::new(Arc::new(Configuration {
284    /// #     base_path: "https://catalog.data.gov/api/3".to_string(),
285    /// #     user_agent: Some("test".to_string()),
286    /// #     client: reqwest::Client::new(),
287    /// #     basic_auth: None, oauth_access_token: None, bearer_access_token: None, api_key: None,
288    /// # }));
289    ///
290    /// // Basic text search
291    /// let results = client.package_search(
292    ///     Some("climate change"),
293    ///     Some(20),
294    ///     Some(0),
295    ///     None
296    /// ).await?;
297    ///
298    /// println!("Found {} total datasets", results.count.unwrap_or(0));
299    /// for package in results.results.unwrap_or_default() {
300    ///     println!("Title: {}", package.title.unwrap_or_default());
301    ///     println!("Organization: {}", package.organization.as_ref()
302    ///         .and_then(|org| org.title.as_deref())
303    ///         .unwrap_or("Unknown"));
304    /// }
305    ///
306    /// // Search with organization filter
307    /// let epa_datasets = client.package_search(
308    ///     Some("water quality"),
309    ///     Some(10),
310    ///     Some(0),
311    ///     Some("organization:epa-gov")
312    /// ).await?;
313    ///
314    /// // Search with multiple filters
315    /// let recent_climate_data = client.package_search(
316    ///     Some("climate"),
317    ///     Some(15),
318    ///     Some(0),
319    ///     Some("res_format:CSV AND metadata_modified:[2020-01-01T00:00:00Z TO NOW]")
320    /// ).await?;
321    /// # Ok(())
322    /// # }
323    /// ```
324    ///
325    /// # Pagination
326    ///
327    /// ```rust,ignore
328    /// // Paginate through all results
329    /// let mut start = 0;
330    /// let page_size = 50;
331    ///
332    /// loop {
333    ///     let results = client.package_search(
334    ///         Some("energy"),
335    ///         Some(page_size),
336    ///         Some(start),
337    ///         None
338    ///     ).await?;
339    ///
340    ///     let packages = results.results.unwrap_or_default();
341    ///     if packages.is_empty() {
342    ///         break;
343    ///     }
344    ///
345    ///     // Process this page of results
346    ///     for package in packages {
347    ///         println!("Processing: {}", package.name);
348    ///     }
349    ///
350    ///     start += page_size;
351    /// }
352    /// ```
353    ///
354    /// # Advanced Filtering
355    ///
356    /// The `fq` parameter supports Solr query syntax for advanced filtering. The
357    /// `q` and `fq` parameters are passed through to CKAN's Solr-backed
358    /// package_search endpoint, so you can use familiar Solr constructs such as:
359    ///
360    /// - `organization:epa-gov` - Filter by organization
361    /// - `res_format:CSV` - Filter by resource format
362    /// - `tags:healthcare` - Filter by tags
363    /// - `metadata_modified:[2020-01-01T00:00:00Z TO NOW]` - Date ranges
364    /// - Combine with `AND`, `OR`, `NOT` operators
365    ///
366    /// Examples:
367    ///
368    /// - Text search with wildcard: `q=climat*`
369    /// - Phrase search: `q="air quality"`
370    /// - Complex filter: `fq=organization:epa-gov AND res_format:CSV`
371    pub async fn package_search(
372        &self,
373        q: Option<&str>,
374        rows: Option<i32>,
375        start: Option<i32>,
376        fq: Option<&str>,
377    ) -> Result<models::PackageSearchResult, CkanError> {
378        let rows_str = rows.map(|r| r.to_string());
379        let start_str = start.map(|s| s.to_string());
380
381        let mut params: Vec<(&str, &str)> = Vec::new();
382        if let Some(q) = q {
383            params.push(("q", q));
384        }
385        if let Some(ref r) = rows_str {
386            params.push(("rows", r));
387        }
388        if let Some(ref s) = start_str {
389            params.push(("start", s));
390        }
391        if let Some(fq) = fq {
392            params.push(("fq", fq));
393        }
394
395        self.call_action("package_search", &params).await
396    }
397
398    /// Retrieve a specific dataset by its ID or name
399    ///
400    /// Fetches complete metadata for a single dataset, including all resources,
401    /// tags, organization details, and custom metadata fields.
402    ///
403    /// # Arguments
404    ///
405    /// * `id` - Dataset ID (UUID) or name (URL-friendly slug)
406    ///
407    /// # Returns
408    ///
409    /// Returns the complete dataset record with all metadata and resources.
410    ///
411    /// # Examples
412    ///
413    /// ```rust
414    /// # use data_gov_ckan::{CkanClient, Configuration};
415    /// # use std::sync::Arc;
416    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
417    /// # let client = CkanClient::new(Arc::new(Configuration {
418    /// #     base_path: "https://catalog.data.gov/api/3".to_string(),
419    /// #     user_agent: Some("test".to_string()),
420    /// #     client: reqwest::Client::new(),
421    /// #     basic_auth: None, oauth_access_token: None, bearer_access_token: None, api_key: None,
422    /// # }));
423    ///
424    /// // Get dataset by name
425    /// let dataset = client.package_show("consumer-complaint-database").await?;
426    ///
427    /// println!("Dataset: {}", dataset.title.unwrap_or_default());
428    /// println!("Description: {}", dataset.notes.unwrap_or_default());
429    ///
430    /// // List all resources in the dataset
431    /// println!("Resources:");
432    /// for resource in dataset.resources.unwrap_or_default() {
433    ///     println!("  - {} ({})",
434    ///         resource.name.as_deref().unwrap_or("Unnamed"),
435    ///         resource.format.as_deref().unwrap_or("Unknown format")
436    ///     );
437    ///
438    ///     if let Some(url) = resource.url {
439    ///         println!("    URL: {}", url);
440    ///     }
441    ///
442    ///     if let Some(size) = resource.size {
443    ///         println!("    Size: {} bytes", size);
444    ///     }
445    /// }
446    ///
447    /// // Show dataset tags
448    /// if let Some(tags) = dataset.tags {
449    ///     let tag_names: Vec<String> = tags.into_iter()
450    ///         .filter_map(|tag| tag.display_name)
451    ///         .collect();
452    ///     println!("Tags: {}", tag_names.join(", "));
453    /// }
454    ///
455    /// // Show organization
456    /// if let Some(org) = dataset.organization {
457    ///     println!("Organization: {}", org.title.unwrap_or_default());
458    /// }
459    ///
460    /// // Get dataset by UUID
461    /// let dataset_by_id = client.package_show("a1b2c3d4-e5f6-7890-abcd-ef1234567890").await?;
462    /// # Ok(())
463    /// # }
464    /// ```
465    ///
466    /// # Error Handling
467    ///
468    /// ```rust,ignore
469    /// match client.package_show("nonexistent-dataset").await {
470    ///     Ok(dataset) => {
471    ///         println!("Found dataset: {}", dataset.title.unwrap_or_default());
472    ///     },
473    ///     Err(CkanError::ApiError { status: 404, .. }) => {
474    ///         println!("Dataset not found");
475    ///     },
476    ///     Err(e) => {
477    ///         println!("Other error: {}", e);
478    ///     }
479    /// }
480    /// ```
481    ///
482    /// # Dataset Metadata
483    ///
484    /// The returned dataset includes rich metadata:
485    ///
486    /// - **Basic Info**: Title, description, notes, license
487    /// - **Resources**: Files, APIs, documentation associated with dataset
488    /// - **Organization**: Publishing agency/department information
489    /// - **Tags**: Subject tags and keywords for discovery
490    /// - **Temporal**: Creation date, modification date, temporal coverage
491    /// - **Spatial**: Geographic coverage and bounding boxes
492    /// - **Custom Fields**: Agency-specific metadata extensions
493    pub async fn package_show(&self, id: &str) -> Result<models::Package, CkanError> {
494        self.call_action("package_show", &[("id", id)]).await
495    }
496
497    /// List all organizations in the CKAN instance
498    pub async fn organization_list(
499        &self,
500        sort: Option<&str>,
501        limit: Option<i32>,
502        offset: Option<i32>,
503    ) -> Result<Vec<String>, CkanError> {
504        let limit_str = limit.map(|l| l.to_string());
505        let offset_str = offset.map(|o| o.to_string());
506
507        let mut params: Vec<(&str, &str)> = Vec::new();
508        if let Some(sort) = sort {
509            params.push(("sort", sort));
510        }
511        if let Some(ref l) = limit_str {
512            params.push(("limit", l));
513        }
514        if let Some(ref o) = offset_str {
515            params.push(("offset", o));
516        }
517
518        self.call_action("organization_list", &params).await
519    }
520
521    /// List all groups in the CKAN instance
522    pub async fn group_list(
523        &self,
524        sort: Option<&str>,
525        limit: Option<i32>,
526        offset: Option<i32>,
527    ) -> Result<Vec<String>, CkanError> {
528        let limit_str = limit.map(|l| l.to_string());
529        let offset_str = offset.map(|o| o.to_string());
530
531        let mut params: Vec<(&str, &str)> = Vec::new();
532        if let Some(sort) = sort {
533            params.push(("sort", sort));
534        }
535        if let Some(ref l) = limit_str {
536            params.push(("limit", l));
537        }
538        if let Some(ref o) = offset_str {
539            params.push(("offset", o));
540        }
541
542        self.call_action("group_list", &params).await
543    }
544
545    /// Get dataset autocomplete suggestions for type-ahead functionality
546    ///
547    /// Provides quick dataset name/title suggestions as the user types, perfect for
548    /// implementing search boxes with autocomplete dropdowns.
549    ///
550    /// # Arguments
551    ///
552    /// * `incomplete` - Partial dataset name or title to search for (e.g., "climat")
553    /// * `limit` - Maximum number of suggestions to return (default: 10, reasonable max: 20)
554    ///
555    /// # Returns
556    ///
557    /// Returns suggestions containing dataset names and titles that match the input.
558    ///
559    /// # Examples
560    ///
561    /// ```rust
562    /// # use data_gov_ckan::{CkanClient, Configuration};
563    /// # use std::sync::Arc;
564    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
565    /// # let client = CkanClient::new(Arc::new(Configuration {
566    /// #     base_path: "https://catalog.data.gov/api/3".to_string(),
567    /// #     user_agent: Some("test".to_string()),
568    /// #     client: reqwest::Client::new(),
569    /// #     basic_auth: None, oauth_access_token: None, bearer_access_token: None, api_key: None,
570    /// # }));
571    ///
572    /// // Get suggestions as user types "elect"
573    /// let suggestions = client.dataset_autocomplete(Some("elect"), Some(5)).await?;
574    ///
575    /// for suggestion in &suggestions {
576    ///     println!("Dataset: {} - {}",
577    ///         suggestion.name.as_deref().unwrap_or("Unknown"),
578    ///         suggestion.title.as_deref().unwrap_or("No title"));
579    /// }
580    /// # Ok(())
581    /// # }
582    /// ```
583    ///
584    /// # UI Integration
585    ///
586    /// This is designed for real-time search suggestions:
587    ///
588    /// ```rust,ignore
589    /// // In your web frontend or CLI app
590    /// async fn on_search_input_change(input: &str, client: &CkanClient) -> Result<(), Box<dyn std::error::Error>> {
591    ///     if input.len() >= 2 { // Start suggesting after 2+ characters
592    ///         let suggestions = client.dataset_autocomplete(Some(input), Some(10)).await?;
593    ///         // Display suggestions in dropdown/list
594    ///         for suggestion in &suggestions {
595    ///             println!("Suggestion: {}", suggestion.title.as_deref().unwrap_or("Unknown"));
596    ///         }
597    ///     }
598    ///     Ok(())
599    /// }
600    /// ```
601    pub async fn dataset_autocomplete(
602        &self,
603        incomplete: Option<&str>,
604        limit: Option<i32>,
605    ) -> Result<Vec<models::DatasetAutocomplete>, CkanError> {
606        let limit_str = limit.map(|l| l.to_string());
607
608        let mut params: Vec<(&str, &str)> = Vec::new();
609        if let Some(q) = incomplete {
610            params.push(("q", q));
611        }
612        if let Some(ref l) = limit_str {
613            params.push(("limit", l));
614        }
615
616        self.call_action("package_autocomplete", &params).await
617    }
618
619    /// Get tag autocomplete suggestions
620    pub async fn tag_autocomplete(
621        &self,
622        incomplete: Option<&str>,
623        limit: Option<i32>,
624        vocabulary_id: Option<&str>,
625    ) -> Result<Vec<String>, CkanError> {
626        let limit_str = limit.map(|l| l.to_string());
627
628        let mut params: Vec<(&str, &str)> = Vec::new();
629        if let Some(q) = incomplete {
630            params.push(("q", q));
631        }
632        if let Some(ref l) = limit_str {
633            params.push(("limit", l));
634        }
635        if let Some(vid) = vocabulary_id {
636            params.push(("vocabulary_id", vid));
637        }
638
639        self.call_action("tag_autocomplete", &params).await
640    }
641
642    /// Get user autocomplete suggestions
643    pub async fn user_autocomplete(
644        &self,
645        q: Option<&str>,
646        limit: Option<i32>,
647        ignore_self: Option<bool>,
648    ) -> Result<Vec<models::UserAutocomplete>, CkanError> {
649        let limit_str = limit.map(|l| l.to_string());
650        let ignore_self_str = ignore_self.map(|b| b.to_string());
651
652        let mut params: Vec<(&str, &str)> = Vec::new();
653        if let Some(q) = q {
654            params.push(("q", q));
655        }
656        if let Some(ref l) = limit_str {
657            params.push(("limit", l));
658        }
659        if let Some(ref i) = ignore_self_str {
660            params.push(("ignore_self", i));
661        }
662
663        self.call_action("user_autocomplete", &params).await
664    }
665
666    /// Get group autocomplete suggestions for filtering and organization
667    ///
668    /// Groups in CKAN represent thematic collections of datasets. This endpoint
669    /// provides autocomplete functionality for group names and titles.
670    ///
671    /// # Arguments
672    ///
673    /// * `q` - Partial group name or title to search for (e.g., "agri")
674    /// * `limit` - Maximum number of suggestions to return (default: 10)
675    ///
676    /// # Returns
677    ///
678    /// Returns group suggestions with names, display names, and metadata.
679    ///
680    /// # Examples
681    ///
682    /// ```rust
683    /// # use data_gov_ckan::{CkanClient, Configuration};
684    /// # use std::sync::Arc;
685    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
686    /// # let client = CkanClient::new(Arc::new(Configuration {
687    /// #     base_path: "https://catalog.data.gov/api/3".to_string(),
688    /// #     user_agent: Some("test".to_string()),
689    /// #     client: reqwest::Client::new(),
690    /// #     basic_auth: None, oauth_access_token: None, bearer_access_token: None, api_key: None,
691    /// # }));
692    ///
693    /// // Find agriculture-related groups
694    /// let groups = client.group_autocomplete(Some("agri"), Some(5)).await?;
695    ///
696    /// println!("Found {} groups", groups.len());
697    /// for group in groups {
698    ///     println!("Group: {} ({})",
699    ///         group.title.as_deref().unwrap_or("Unknown"),
700    ///         group.name.as_deref().unwrap_or("Unknown"));
701    /// }
702    /// # Ok(())
703    /// # }
704    /// ```
705    ///
706    /// # Common Use Cases
707    ///
708    /// ```rust,ignore
709    /// // Building category filters for search UI
710    /// let science_groups = client.group_autocomplete(Some("science"), Some(10)).await?;
711    ///
712    /// // Finding groups for dataset categorization
713    /// let energy_groups = client.group_autocomplete(Some("energy"), Some(5)).await?;
714    /// ```
715    pub async fn group_autocomplete(
716        &self,
717        q: Option<&str>,
718        limit: Option<i32>,
719    ) -> Result<Vec<models::GroupAutocomplete>, CkanError> {
720        let limit_str = limit.map(|l| l.to_string());
721
722        let mut params: Vec<(&str, &str)> = Vec::new();
723        if let Some(q) = q {
724            params.push(("q", q));
725        }
726        if let Some(ref l) = limit_str {
727            params.push(("limit", l));
728        }
729
730        self.call_action("group_autocomplete", &params).await
731    }
732
733    /// Get organization autocomplete suggestions
734    pub async fn organization_autocomplete(
735        &self,
736        q: Option<&str>,
737        limit: Option<i32>,
738    ) -> Result<Vec<models::OrganizationAutocomplete>, CkanError> {
739        let limit_str = limit.map(|l| l.to_string());
740
741        let mut params: Vec<(&str, &str)> = Vec::new();
742        if let Some(q) = q {
743            params.push(("q", q));
744        }
745        if let Some(ref l) = limit_str {
746            params.push(("limit", l));
747        }
748
749        self.call_action("organization_autocomplete", &params).await
750    }
751
752    /// Get resource format autocomplete suggestions
753    pub async fn resource_format_autocomplete(
754        &self,
755        incomplete: Option<&str>,
756        limit: Option<i32>,
757    ) -> Result<Vec<String>, CkanError> {
758        let limit_str = limit.map(|l| l.to_string());
759
760        let mut params: Vec<(&str, &str)> = Vec::new();
761        if let Some(q) = incomplete {
762            params.push(("q", q));
763        }
764        if let Some(ref l) = limit_str {
765            params.push(("limit", l));
766        }
767
768        self.call_action("format_autocomplete", &params).await
769    }
770}