mindat_rs/
client.rs

1//! HTTP client for the Mindat API.
2
3use reqwest::Client;
4use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue, USER_AGENT};
5use std::time::Duration;
6use url::Url;
7
8use crate::error::{MindatError, Result};
9use crate::models::*;
10
11/// Default base URL for the Mindat API (v1).
12/// Note: Must end with a slash for proper URL joining.
13pub const DEFAULT_BASE_URL: &str = "https://api.mindat.org/v1/";
14
15/// User-Agent string for API requests.
16/// Using a browser-like User-Agent to avoid Cloudflare blocks.
17const USER_AGENT_STRING: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
18
19/// Create a configured HTTP client with proper timeouts and settings.
20fn create_http_client() -> Client {
21    Client::builder()
22        .timeout(Duration::from_secs(30))
23        .connect_timeout(Duration::from_secs(10))
24        .pool_max_idle_per_host(5)
25        .build()
26        .expect("Failed to create HTTP client")
27}
28
29/// Client for interacting with the Mindat API.
30#[derive(Debug, Clone)]
31pub struct MindatClient {
32    http: Client,
33    base_url: Url,
34    token: Option<String>,
35}
36
37impl MindatClient {
38    /// Create a new client with the given API token.
39    ///
40    /// # Example
41    ///
42    /// ```no_run
43    /// use mindat_rs::MindatClient;
44    ///
45    /// let client = MindatClient::new("your-api-token");
46    /// ```
47    pub fn new(token: impl Into<String>) -> Self {
48        Self {
49            http: create_http_client(),
50            base_url: Url::parse(DEFAULT_BASE_URL).unwrap(),
51            token: Some(token.into()),
52        }
53    }
54
55    /// Create a new client without authentication.
56    /// Some endpoints (like minerals_ima) work without authentication.
57    pub fn anonymous() -> Self {
58        Self {
59            http: create_http_client(),
60            base_url: Url::parse(DEFAULT_BASE_URL).unwrap(),
61            token: None,
62        }
63    }
64
65    /// Create a new client builder for more configuration options.
66    pub fn builder() -> MindatClientBuilder {
67        MindatClientBuilder::new()
68    }
69
70    /// Set the API token.
71    pub fn set_token(&mut self, token: impl Into<String>) {
72        self.token = Some(token.into());
73    }
74
75    /// Get the base URL.
76    pub fn base_url(&self) -> &Url {
77        &self.base_url
78    }
79
80    /// Build request headers.
81    fn headers(&self) -> Result<HeaderMap> {
82        let mut headers = HeaderMap::new();
83
84        // Always include User-Agent and Accept to avoid Cloudflare blocks
85        headers.insert(USER_AGENT, HeaderValue::from_static(USER_AGENT_STRING));
86        headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
87
88        if let Some(ref token) = self.token {
89            let auth_value = format!("Token {}", token);
90            headers.insert(
91                AUTHORIZATION,
92                HeaderValue::from_str(&auth_value).map_err(|_| {
93                    MindatError::InvalidParameter("Invalid token format".to_string())
94                })?,
95            );
96        }
97        Ok(headers)
98    }
99
100    /// Make a GET request to the API.
101    async fn get<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
102        // Strip leading slash to ensure proper URL joining with base URL
103        let path = path.strip_prefix('/').unwrap_or(path);
104        let url = self.base_url.join(path)?;
105        let response = self.http.get(url).headers(self.headers()?).send().await?;
106
107        self.handle_response(response).await
108    }
109
110    /// Make a GET request with query parameters.
111    async fn get_with_query<T, Q>(&self, path: &str, query: &Q) -> Result<T>
112    where
113        T: serde::de::DeserializeOwned,
114        Q: serde::Serialize,
115    {
116        // Strip leading slash to ensure proper URL joining with base URL
117        let path = path.strip_prefix('/').unwrap_or(path);
118        let url = self.base_url.join(path)?;
119        let response = self
120            .http
121            .get(url)
122            .headers(self.headers()?)
123            .query(query)
124            .send()
125            .await?;
126
127        self.handle_response(response).await
128    }
129
130    /// Handle API response.
131    async fn handle_response<T: serde::de::DeserializeOwned>(
132        &self,
133        response: reqwest::Response,
134    ) -> Result<T> {
135        let status = response.status();
136
137        if status.is_success() {
138            let text = response.text().await?;
139            serde_json::from_str(&text).map_err(MindatError::from)
140        } else {
141            let status_code = status.as_u16();
142            let message = response
143                .text()
144                .await
145                .unwrap_or_else(|_| "Unknown error".to_string());
146
147            match status_code {
148                401 => Err(MindatError::AuthenticationRequired),
149                404 => Err(MindatError::NotFound(message)),
150                429 => Err(MindatError::RateLimited),
151                _ => Err(MindatError::Api {
152                    status: status_code,
153                    message,
154                }),
155            }
156        }
157    }
158
159    // ==================== Countries ====================
160
161    /// List all countries.
162    ///
163    /// # Example
164    ///
165    /// ```no_run
166    /// # async fn example() -> mindat_rs::Result<()> {
167    /// use mindat_rs::MindatClient;
168    ///
169    /// let client = MindatClient::new("your-token");
170    /// let countries = client.countries().await?;
171    /// for country in countries.results {
172    ///     println!("{}: {}", country.iso, country.text);
173    /// }
174    /// # Ok(())
175    /// # }
176    /// ```
177    pub async fn countries(&self) -> Result<PaginatedResponse<Country>> {
178        // Note: The /countries/ endpoint may not exist in v1 API
179        // Countries are primarily available as filters on the localities endpoint
180        self.get("/countries/").await
181    }
182
183    /// List countries with pagination.
184    pub async fn countries_page(&self, page: i32) -> Result<PaginatedResponse<Country>> {
185        #[derive(serde::Serialize)]
186        struct Query {
187            page: i32,
188        }
189        // Note: The /countries/ endpoint may not exist in v1 API
190        self.get_with_query("/countries/", &Query { page }).await
191    }
192
193    /// Get a specific country by ID.
194    pub async fn country(&self, id: i32) -> Result<Country> {
195        self.get(&format!("/countries/{}/", id)).await
196    }
197
198    // ==================== Geomaterials ====================
199
200    /// List geomaterials with optional filters.
201    ///
202    /// # Example
203    ///
204    /// ```no_run
205    /// # async fn example() -> mindat_rs::Result<()> {
206    /// use mindat_rs::{MindatClient, GeomaterialsQuery};
207    ///
208    /// let client = MindatClient::new("your-token");
209    ///
210    /// // Get IMA-approved minerals containing copper
211    /// let query = GeomaterialsQuery::new()
212    ///     .ima_approved(true)
213    ///     .with_elements("Cu")
214    ///     .page_size(50);
215    ///
216    /// let minerals = client.geomaterials(query).await?;
217    /// for mineral in minerals.results {
218    ///     println!("{}: {:?}", mineral.id, mineral.name);
219    /// }
220    /// # Ok(())
221    /// # }
222    /// ```
223    pub async fn geomaterials(
224        &self,
225        query: GeomaterialsQuery,
226    ) -> Result<PaginatedResponse<Geomaterial>> {
227        #[derive(serde::Serialize)]
228        struct QueryParams {
229            #[serde(skip_serializing_if = "Option::is_none")]
230            name: Option<String>,
231            #[serde(skip_serializing_if = "Option::is_none")]
232            q: Option<String>,
233            #[serde(skip_serializing_if = "Option::is_none")]
234            ima: Option<bool>,
235            #[serde(skip_serializing_if = "Option::is_none")]
236            elements_inc: Option<String>,
237            #[serde(skip_serializing_if = "Option::is_none")]
238            elements_exc: Option<String>,
239            #[serde(skip_serializing_if = "Option::is_none")]
240            colour: Option<String>,
241            #[serde(skip_serializing_if = "Option::is_none")]
242            streak: Option<String>,
243            #[serde(skip_serializing_if = "Option::is_none")]
244            hardness_min: Option<f32>,
245            #[serde(skip_serializing_if = "Option::is_none")]
246            hardness_max: Option<f32>,
247            #[serde(skip_serializing_if = "Option::is_none")]
248            density_min: Option<f64>,
249            #[serde(skip_serializing_if = "Option::is_none")]
250            density_max: Option<f64>,
251            #[serde(skip_serializing_if = "Option::is_none")]
252            ri_min: Option<f32>,
253            #[serde(skip_serializing_if = "Option::is_none")]
254            ri_max: Option<f32>,
255            #[serde(skip_serializing_if = "Option::is_none")]
256            bi_min: Option<String>,
257            #[serde(skip_serializing_if = "Option::is_none")]
258            bi_max: Option<String>,
259            #[serde(skip_serializing_if = "Option::is_none")]
260            optical2v_min: Option<String>,
261            #[serde(skip_serializing_if = "Option::is_none")]
262            optical2v_max: Option<String>,
263            #[serde(skip_serializing_if = "Option::is_none")]
264            varietyof: Option<i32>,
265            #[serde(skip_serializing_if = "Option::is_none")]
266            synid: Option<i32>,
267            #[serde(skip_serializing_if = "Option::is_none")]
268            polytypeof: Option<i32>,
269            #[serde(skip_serializing_if = "Option::is_none")]
270            groupid: Option<i32>,
271            #[serde(skip_serializing_if = "Option::is_none")]
272            non_utf: Option<bool>,
273            #[serde(skip_serializing_if = "Option::is_none")]
274            meteoritical_code: Option<String>,
275            #[serde(skip_serializing_if = "Option::is_none")]
276            meteoritical_code_exists: Option<bool>,
277            #[serde(skip_serializing_if = "Option::is_none")]
278            updated_at: Option<String>,
279            #[serde(skip_serializing_if = "Option::is_none")]
280            fields: Option<String>,
281            #[serde(skip_serializing_if = "Option::is_none")]
282            omit: Option<String>,
283            #[serde(skip_serializing_if = "Option::is_none")]
284            ordering: Option<String>,
285            #[serde(skip_serializing_if = "Option::is_none")]
286            page: Option<i32>,
287            #[serde(skip_serializing_if = "Option::is_none")]
288            page_size: Option<i32>,
289        }
290
291        let params = QueryParams {
292            name: query.name,
293            q: query.q,
294            ima: query.ima,
295            elements_inc: query.elements_inc,
296            elements_exc: query.elements_exc,
297            colour: query.colour,
298            streak: query.streak,
299            hardness_min: query.hardness_min,
300            hardness_max: query.hardness_max,
301            density_min: query.density_min,
302            density_max: query.density_max,
303            ri_min: query.ri_min,
304            ri_max: query.ri_max,
305            bi_min: query.bi_min,
306            bi_max: query.bi_max,
307            optical2v_min: query.optical2v_min,
308            optical2v_max: query.optical2v_max,
309            varietyof: query.varietyof,
310            synid: query.synid,
311            polytypeof: query.polytypeof,
312            groupid: query.groupid,
313            non_utf: query.non_utf,
314            meteoritical_code: query.meteoritical_code,
315            meteoritical_code_exists: query.meteoritical_code_exists,
316            updated_at: query.updated_at,
317            fields: query.fields,
318            omit: query.omit,
319            ordering: query.ordering.map(|o| o.to_string()),
320            page: query.page,
321            page_size: query.page_size,
322        };
323
324        self.get_with_query("/geomaterials/", &params).await
325    }
326
327    /// Get a specific geomaterial by ID.
328    pub async fn geomaterial(&self, id: i32) -> Result<Geomaterial> {
329        self.get(&format!("/geomaterials/{}/", id)).await
330    }
331
332    /// Get varieties of a specific geomaterial.
333    pub async fn geomaterial_varieties(&self, id: i32) -> Result<Geomaterial> {
334        self.get(&format!("/geomaterials/{}/varieties/", id)).await
335    }
336
337    /// Search for geomaterials.
338    pub async fn geomaterials_search(
339        &self,
340        q: &str,
341        size: Option<i32>,
342    ) -> Result<Vec<serde_json::Value>> {
343        #[derive(serde::Serialize)]
344        struct Query<'a> {
345            q: &'a str,
346            #[serde(skip_serializing_if = "Option::is_none")]
347            size: Option<i32>,
348        }
349        self.get_with_query("/geomaterials-search/", &Query { q, size })
350            .await
351    }
352
353    // ==================== Localities ====================
354
355    /// List localities with optional filters.
356    ///
357    /// # Example
358    ///
359    /// ```no_run
360    /// # async fn example() -> mindat_rs::Result<()> {
361    /// use mindat_rs::{MindatClient, LocalitiesQuery};
362    ///
363    /// let client = MindatClient::new("your-token");
364    ///
365    /// // Get localities in Brazil
366    /// let query = LocalitiesQuery::new().country("Brazil");
367    /// let localities = client.localities(query).await?;
368    /// for loc in localities.results {
369    ///     println!("{}: {:?}", loc.id, loc.txt);
370    /// }
371    /// # Ok(())
372    /// # }
373    /// ```
374    pub async fn localities(
375        &self,
376        query: LocalitiesQuery,
377    ) -> Result<CursorPaginatedResponse<Locality>> {
378        #[derive(serde::Serialize)]
379        struct QueryParams {
380            #[serde(skip_serializing_if = "Option::is_none")]
381            country: Option<String>,
382            #[serde(skip_serializing_if = "Option::is_none")]
383            txt: Option<String>,
384            #[serde(skip_serializing_if = "Option::is_none")]
385            description: Option<String>,
386            #[serde(skip_serializing_if = "Option::is_none")]
387            elements_inc: Option<String>,
388            #[serde(skip_serializing_if = "Option::is_none")]
389            elements_exc: Option<String>,
390            #[serde(skip_serializing_if = "Option::is_none")]
391            updated_at: Option<String>,
392            #[serde(skip_serializing_if = "Option::is_none")]
393            fields: Option<String>,
394            #[serde(skip_serializing_if = "Option::is_none")]
395            omit: Option<String>,
396            #[serde(skip_serializing_if = "Option::is_none")]
397            cursor: Option<String>,
398            #[serde(skip_serializing_if = "Option::is_none")]
399            page_size: Option<i32>,
400            #[serde(skip_serializing_if = "Option::is_none")]
401            page: Option<i32>,
402        }
403
404        let params = QueryParams {
405            country: query.country,
406            txt: query.txt,
407            description: query.description,
408            elements_inc: query.elements_inc,
409            elements_exc: query.elements_exc,
410            updated_at: query.updated_at,
411            fields: query.fields,
412            omit: query.omit,
413            cursor: query.cursor,
414            page_size: query.page_size,
415            page: query.page,
416        };
417
418        self.get_with_query("/localities/", &params).await
419    }
420
421    /// Get a specific locality by ID.
422    pub async fn locality(&self, id: i32) -> Result<Locality> {
423        self.get(&format!("/localities/{}/", id)).await
424    }
425
426    // ==================== Locality Metadata ====================
427
428    /// List locality ages.
429    pub async fn locality_ages(&self, page: Option<i32>) -> Result<PaginatedResponse<LocalityAge>> {
430        #[derive(serde::Serialize)]
431        struct Query {
432            #[serde(skip_serializing_if = "Option::is_none")]
433            page: Option<i32>,
434        }
435        self.get_with_query("/locality-age/", &Query { page }).await
436    }
437
438    /// Get a specific locality age by ID.
439    pub async fn locality_age(&self, age_id: i32) -> Result<LocalityAge> {
440        self.get(&format!("/locality-age/{}/", age_id)).await
441    }
442
443    /// List locality statuses.
444    pub async fn locality_statuses(
445        &self,
446        page: Option<i32>,
447    ) -> Result<PaginatedResponse<LocalityStatus>> {
448        #[derive(serde::Serialize)]
449        struct Query {
450            #[serde(skip_serializing_if = "Option::is_none")]
451            page: Option<i32>,
452        }
453        self.get_with_query("/locality-status/", &Query { page })
454            .await
455    }
456
457    /// Get a specific locality status by ID.
458    pub async fn locality_status(&self, ls_id: i32) -> Result<LocalityStatus> {
459        self.get(&format!("/locality-status/{}/", ls_id)).await
460    }
461
462    /// List locality types.
463    pub async fn locality_types(
464        &self,
465        page: Option<i32>,
466    ) -> Result<PaginatedResponse<LocalityType>> {
467        #[derive(serde::Serialize)]
468        struct Query {
469            #[serde(skip_serializing_if = "Option::is_none")]
470            page: Option<i32>,
471        }
472        self.get_with_query("/locality-type/", &Query { page })
473            .await
474    }
475
476    /// Get a specific locality type by ID.
477    pub async fn locality_type(&self, lt_id: i32) -> Result<LocalityType> {
478        self.get(&format!("/locality-type/{}/", lt_id)).await
479    }
480
481    /// List geographic regions.
482    pub async fn geo_regions(
483        &self,
484        page: Option<i32>,
485    ) -> Result<PaginatedResponse<serde_json::Value>> {
486        #[derive(serde::Serialize)]
487        struct Query {
488            #[serde(skip_serializing_if = "Option::is_none")]
489            page: Option<i32>,
490        }
491        self.get_with_query("/locgeoregion2/", &Query { page })
492            .await
493    }
494
495    // ==================== IMA Minerals ====================
496
497    /// List IMA-approved minerals.
498    ///
499    /// # Example
500    ///
501    /// ```no_run
502    /// # async fn example() -> mindat_rs::Result<()> {
503    /// use mindat_rs::{MindatClient, ImaMineralsQuery};
504    ///
505    /// let client = MindatClient::anonymous(); // No auth required
506    /// let query = ImaMineralsQuery::new().page_size(100);
507    /// let minerals = client.minerals_ima(query).await?;
508    /// for mineral in minerals.results {
509    ///     println!("{}: {:?}", mineral.id, mineral.name);
510    /// }
511    /// # Ok(())
512    /// # }
513    /// ```
514    pub async fn minerals_ima(
515        &self,
516        query: ImaMineralsQuery,
517    ) -> Result<PaginatedResponse<ImaMaterial>> {
518        #[derive(serde::Serialize)]
519        struct QueryParams {
520            #[serde(skip_serializing_if = "Option::is_none")]
521            q: Option<String>,
522            #[serde(skip_serializing_if = "Option::is_none")]
523            ima: Option<i32>,
524            #[serde(skip_serializing_if = "Option::is_none")]
525            updated_at: Option<String>,
526            #[serde(skip_serializing_if = "Option::is_none")]
527            fields: Option<String>,
528            #[serde(skip_serializing_if = "Option::is_none")]
529            omit: Option<String>,
530            #[serde(skip_serializing_if = "Option::is_none")]
531            page: Option<i32>,
532            #[serde(skip_serializing_if = "Option::is_none")]
533            page_size: Option<i32>,
534        }
535
536        let params = QueryParams {
537            q: query.q,
538            ima: query.ima,
539            updated_at: query.updated_at,
540            fields: query.fields,
541            omit: query.omit,
542            page: query.page,
543            page_size: query.page_size,
544        };
545
546        self.get_with_query("/minerals-ima/", &params).await
547    }
548
549    /// Get a specific IMA mineral by ID.
550    pub async fn mineral_ima(&self, id: i32) -> Result<Geomaterial> {
551        self.get(&format!("/minerals-ima/{}/", id)).await
552    }
553
554    // ==================== Classification Systems ====================
555
556    /// Get Dana 8th edition classification groups.
557    pub async fn dana8_groups(&self) -> Result<serde_json::Value> {
558        self.get("/dana-8/groups/").await
559    }
560
561    /// Get Dana 8th edition classification subgroups.
562    pub async fn dana8_subgroups(&self) -> Result<serde_json::Value> {
563        self.get("/dana-8/subgroups/").await
564    }
565
566    /// Get a specific Dana 8th edition classification.
567    pub async fn dana8(&self, id: i32) -> Result<serde_json::Value> {
568        self.get(&format!("/dana-8/{}/", id)).await
569    }
570
571    /// Get Nickel-Strunz 10th edition classification classes.
572    pub async fn strunz10_classes(&self) -> Result<serde_json::Value> {
573        self.get("/nickel-strunz-10/classes/").await
574    }
575
576    /// Get Nickel-Strunz 10th edition classification subclasses.
577    pub async fn strunz10_subclasses(&self) -> Result<serde_json::Value> {
578        self.get("/nickel-strunz-10/subclasses/").await
579    }
580
581    /// Get Nickel-Strunz 10th edition classification families.
582    pub async fn strunz10_families(&self) -> Result<serde_json::Value> {
583        self.get("/nickel-strunz-10/families/").await
584    }
585
586    /// Get a specific Nickel-Strunz 10th edition classification.
587    pub async fn strunz10(&self, id: i32) -> Result<serde_json::Value> {
588        self.get(&format!("/nickel-strunz-10/{}/", id)).await
589    }
590
591    // ==================== Other ====================
592
593    /// Get photo count statistics.
594    pub async fn photocount(&self) -> Result<serde_json::Value> {
595        self.get("/photo-count/").await
596    }
597}
598
599/// Builder for MindatClient configuration.
600#[derive(Debug, Clone)]
601pub struct MindatClientBuilder {
602    token: Option<String>,
603    base_url: String,
604    timeout: Option<std::time::Duration>,
605}
606
607impl MindatClientBuilder {
608    /// Create a new builder.
609    pub fn new() -> Self {
610        Self {
611            token: None,
612            base_url: DEFAULT_BASE_URL.to_string(),
613            timeout: None,
614        }
615    }
616
617    /// Set the API token.
618    pub fn token(mut self, token: impl Into<String>) -> Self {
619        self.token = Some(token.into());
620        self
621    }
622
623    /// Set a custom base URL.
624    pub fn base_url(mut self, url: impl Into<String>) -> Self {
625        self.base_url = url.into();
626        self
627    }
628
629    /// Set request timeout.
630    pub fn timeout(mut self, timeout: std::time::Duration) -> Self {
631        self.timeout = Some(timeout);
632        self
633    }
634
635    /// Build the client.
636    pub fn build(self) -> Result<MindatClient> {
637        let mut client_builder = Client::builder();
638
639        if let Some(timeout) = self.timeout {
640            client_builder = client_builder.timeout(timeout);
641        }
642
643        let http = client_builder.build().map_err(MindatError::Request)?;
644
645        let base_url = Url::parse(&self.base_url)?;
646
647        Ok(MindatClient {
648            http,
649            base_url,
650            token: self.token,
651        })
652    }
653}
654
655impl Default for MindatClientBuilder {
656    fn default() -> Self {
657        Self::new()
658    }
659}