1use crate::models;
4use serde::de::DeserializeOwned;
5use serde_json::Value;
6use std::sync::Arc;
7
8#[derive(Debug, Clone)]
14pub struct Configuration {
15 pub base_path: String,
17 pub user_agent: Option<String>,
19 pub client: reqwest::Client,
21}
22
23impl Configuration {
24 pub fn new() -> Self {
26 Self::default()
27 }
28}
29
30impl Default for Configuration {
31 fn default() -> Self {
32 Self {
33 base_path: "https://catalog.data.gov".to_owned(),
34 user_agent: Some(concat!("data-gov-rs/", env!("CARGO_PKG_VERSION")).to_owned()),
35 client: reqwest::Client::new(),
36 }
37 }
38}
39
40pub struct CatalogClient {
45 configuration: Arc<Configuration>,
46}
47
48impl std::fmt::Debug for CatalogClient {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 f.debug_struct("CatalogClient")
51 .field("base_path", &self.configuration.base_path)
52 .finish()
53 }
54}
55
56#[derive(Debug)]
58pub enum CatalogError {
59 RequestError(Box<dyn std::error::Error + Send + Sync>),
61 ParseError(serde_json::Error),
63 ApiError {
65 status: u16,
67 message: String,
69 },
70}
71
72impl std::fmt::Display for CatalogError {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 match self {
75 CatalogError::RequestError(e) => write!(f, "Request error: {e}"),
76 CatalogError::ParseError(e) => write!(f, "Parse error: {e}"),
77 CatalogError::ApiError { status, message } => {
78 write!(f, "Catalog API error ({status}): {message}")
79 }
80 }
81 }
82}
83
84impl std::error::Error for CatalogError {}
85
86#[derive(Debug, Default, Clone)]
92pub struct SearchParams {
93 pub q: Option<String>,
95 pub sort: Option<String>,
97 pub per_page: Option<i32>,
99 pub org_slug: Option<String>,
101 pub org_type: Option<String>,
103 pub keyword: Vec<String>,
105 pub spatial_filter: Option<String>,
107 pub spatial_geometry: Option<Value>,
109 pub spatial_within: Option<bool>,
111 pub after: Option<String>,
113 pub slug: Option<String>,
115}
116
117impl SearchParams {
118 pub fn new() -> Self {
120 Self::default()
121 }
122
123 pub fn q(mut self, q: impl Into<String>) -> Self {
125 self.q = Some(q.into());
126 self
127 }
128
129 pub fn sort(mut self, sort: impl Into<String>) -> Self {
131 self.sort = Some(sort.into());
132 self
133 }
134
135 pub fn per_page(mut self, per_page: i32) -> Self {
137 self.per_page = Some(per_page);
138 self
139 }
140
141 pub fn org_slug(mut self, slug: impl Into<String>) -> Self {
143 self.org_slug = Some(slug.into());
144 self
145 }
146
147 pub fn org_type(mut self, org_type: impl Into<String>) -> Self {
149 self.org_type = Some(org_type.into());
150 self
151 }
152
153 pub fn keyword(mut self, keyword: impl Into<String>) -> Self {
155 self.keyword.push(keyword.into());
156 self
157 }
158
159 pub fn keywords<I, S>(mut self, keywords: I) -> Self
161 where
162 I: IntoIterator<Item = S>,
163 S: Into<String>,
164 {
165 self.keyword = keywords.into_iter().map(Into::into).collect();
166 self
167 }
168
169 pub fn spatial_filter(mut self, mode: impl Into<String>) -> Self {
171 self.spatial_filter = Some(mode.into());
172 self
173 }
174
175 pub fn spatial_geometry(mut self, geometry: Value) -> Self {
177 self.spatial_geometry = Some(geometry);
178 self
179 }
180
181 pub fn spatial_within(mut self, within: bool) -> Self {
183 self.spatial_within = Some(within);
184 self
185 }
186
187 pub fn after(mut self, after: impl Into<String>) -> Self {
189 self.after = Some(after.into());
190 self
191 }
192
193 pub fn slug(mut self, slug: impl Into<String>) -> Self {
195 self.slug = Some(slug.into());
196 self
197 }
198
199 fn to_query(&self) -> Vec<(&'static str, String)> {
201 let mut q: Vec<(&'static str, String)> = Vec::new();
202 if let Some(v) = &self.q {
203 q.push(("q", v.clone()));
204 }
205 if let Some(v) = &self.sort {
206 q.push(("sort", v.clone()));
207 }
208 if let Some(v) = self.per_page {
209 q.push(("per_page", v.to_string()));
210 }
211 if let Some(v) = &self.org_slug {
212 q.push(("org_slug", v.clone()));
213 }
214 if let Some(v) = &self.org_type {
215 q.push(("org_type", v.clone()));
216 }
217 for kw in &self.keyword {
218 q.push(("keyword", kw.clone()));
219 }
220 if let Some(v) = &self.spatial_filter {
221 q.push(("spatial_filter", v.clone()));
222 }
223 if let Some(v) = &self.spatial_geometry {
224 q.push(("spatial_geometry", v.to_string()));
225 }
226 if let Some(v) = self.spatial_within {
227 q.push(("spatial_within", v.to_string()));
228 }
229 if let Some(v) = &self.after {
230 q.push(("after", v.clone()));
231 }
232 if let Some(v) = &self.slug {
233 q.push(("slug", v.clone()));
234 }
235 q
236 }
237}
238
239impl CatalogClient {
240 pub fn new(configuration: Arc<Configuration>) -> Self {
242 Self { configuration }
243 }
244
245 fn url(&self, path: &str) -> String {
247 let base = self.configuration.base_path.trim_end_matches('/');
248 format!("{base}{path}")
249 }
250
251 async fn get_json<T: DeserializeOwned, Q: serde::Serialize + ?Sized>(
253 &self,
254 path: &str,
255 params: &Q,
256 ) -> Result<T, CatalogError> {
257 let mut req = self.configuration.client.get(self.url(path)).query(params);
258 if let Some(ua) = &self.configuration.user_agent {
259 req = req.header(reqwest::header::USER_AGENT, ua);
260 }
261 let response = req
262 .send()
263 .await
264 .map_err(|e| CatalogError::RequestError(Box::new(e)))?;
265
266 if !response.status().is_success() {
267 let status = response.status().as_u16();
268 let message = response
269 .text()
270 .await
271 .unwrap_or_else(|_| "<no body>".to_string());
272 return Err(CatalogError::ApiError { status, message });
273 }
274
275 let bytes = response
276 .bytes()
277 .await
278 .map_err(|e| CatalogError::RequestError(Box::new(e)))?;
279 serde_json::from_slice(&bytes).map_err(CatalogError::ParseError)
280 }
281
282 pub async fn search(
291 &self,
292 params: SearchParams,
293 ) -> Result<models::SearchResponse, CatalogError> {
294 let query = params.to_query();
295 self.get_json("/search", &query).await
296 }
297
298 pub async fn dataset_by_slug(
313 &self,
314 slug: &str,
315 ) -> Result<Option<models::SearchHit>, CatalogError> {
316 let params = SearchParams::new().q(slug).per_page(20);
317 let response: models::SearchResponse = self.search(params).await?;
318 Ok(response
319 .results
320 .into_iter()
321 .find(|hit| hit.slug.as_deref() == Some(slug)))
322 }
323
324 pub async fn organizations(&self) -> Result<models::OrganizationsResponse, CatalogError> {
329 self.get_json("/api/organizations", &[(); 0]).await
330 }
331
332 pub async fn keywords(
337 &self,
338 size: Option<i32>,
339 min_count: Option<i32>,
340 ) -> Result<models::KeywordsResponse, CatalogError> {
341 let mut params: Vec<(&str, String)> = Vec::new();
342 if let Some(s) = size {
343 params.push(("size", s.to_string()));
344 }
345 if let Some(m) = min_count {
346 params.push(("min_count", m.to_string()));
347 }
348 self.get_json("/api/keywords", ¶ms).await
349 }
350
351 pub async fn locations_search(
353 &self,
354 q: &str,
355 size: Option<i32>,
356 ) -> Result<models::LocationsResponse, CatalogError> {
357 let mut params: Vec<(&str, String)> = vec![("q", q.to_string())];
358 if let Some(s) = size {
359 params.push(("size", s.to_string()));
360 }
361 self.get_json("/api/locations/search", ¶ms).await
362 }
363
364 pub async fn location_geometry(&self, id: &str) -> Result<Value, CatalogError> {
370 let path = format!("/api/location/{id}");
371 self.get_json(&path, &[(); 0]).await
372 }
373
374 pub async fn harvest_record(&self, id: &str) -> Result<models::HarvestRecord, CatalogError> {
376 let path = format!("/harvest_record/{id}");
377 self.get_json(&path, &[(); 0]).await
378 }
379
380 pub async fn harvest_record_raw(&self, id: &str) -> Result<Value, CatalogError> {
386 let path = format!("/harvest_record/{id}/raw");
387 self.get_json(&path, &[(); 0]).await
388 }
389
390 pub async fn harvest_record_transformed(
392 &self,
393 id: &str,
394 ) -> Result<models::Dataset, CatalogError> {
395 let path = format!("/harvest_record/{id}/transformed");
396 self.get_json(&path, &[(); 0]).await
397 }
398}