1use 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
11pub const DEFAULT_BASE_URL: &str = "https://api.mindat.org/v1/";
14
15const 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
19fn 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#[derive(Debug, Clone)]
31pub struct MindatClient {
32 http: Client,
33 base_url: Url,
34 token: Option<String>,
35}
36
37impl MindatClient {
38 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 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 pub fn builder() -> MindatClientBuilder {
67 MindatClientBuilder::new()
68 }
69
70 pub fn set_token(&mut self, token: impl Into<String>) {
72 self.token = Some(token.into());
73 }
74
75 pub fn base_url(&self) -> &Url {
77 &self.base_url
78 }
79
80 fn headers(&self) -> Result<HeaderMap> {
82 let mut headers = HeaderMap::new();
83
84 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 async fn get<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
102 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 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 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 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 pub async fn countries(&self) -> Result<PaginatedResponse<Country>> {
178 self.get("/countries/").await
181 }
182
183 pub async fn countries_page(&self, page: i32) -> Result<PaginatedResponse<Country>> {
185 #[derive(serde::Serialize)]
186 struct Query {
187 page: i32,
188 }
189 self.get_with_query("/countries/", &Query { page }).await
191 }
192
193 pub async fn country(&self, id: i32) -> Result<Country> {
195 self.get(&format!("/countries/{}/", id)).await
196 }
197
198 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/", ¶ms).await
325 }
326
327 pub async fn geomaterial(&self, id: i32) -> Result<Geomaterial> {
329 self.get(&format!("/geomaterials/{}/", id)).await
330 }
331
332 pub async fn geomaterial_varieties(&self, id: i32) -> Result<Geomaterial> {
334 self.get(&format!("/geomaterials/{}/varieties/", id)).await
335 }
336
337 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 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/", ¶ms).await
419 }
420
421 pub async fn locality(&self, id: i32) -> Result<Locality> {
423 self.get(&format!("/localities/{}/", id)).await
424 }
425
426 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 pub async fn locality_age(&self, age_id: i32) -> Result<LocalityAge> {
440 self.get(&format!("/locality-age/{}/", age_id)).await
441 }
442
443 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 pub async fn locality_status(&self, ls_id: i32) -> Result<LocalityStatus> {
459 self.get(&format!("/locality-status/{}/", ls_id)).await
460 }
461
462 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 pub async fn locality_type(&self, lt_id: i32) -> Result<LocalityType> {
478 self.get(&format!("/locality-type/{}/", lt_id)).await
479 }
480
481 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 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/", ¶ms).await
547 }
548
549 pub async fn mineral_ima(&self, id: i32) -> Result<Geomaterial> {
551 self.get(&format!("/minerals-ima/{}/", id)).await
552 }
553
554 pub async fn dana8_groups(&self) -> Result<serde_json::Value> {
558 self.get("/dana-8/groups/").await
559 }
560
561 pub async fn dana8_subgroups(&self) -> Result<serde_json::Value> {
563 self.get("/dana-8/subgroups/").await
564 }
565
566 pub async fn dana8(&self, id: i32) -> Result<serde_json::Value> {
568 self.get(&format!("/dana-8/{}/", id)).await
569 }
570
571 pub async fn strunz10_classes(&self) -> Result<serde_json::Value> {
573 self.get("/nickel-strunz-10/classes/").await
574 }
575
576 pub async fn strunz10_subclasses(&self) -> Result<serde_json::Value> {
578 self.get("/nickel-strunz-10/subclasses/").await
579 }
580
581 pub async fn strunz10_families(&self) -> Result<serde_json::Value> {
583 self.get("/nickel-strunz-10/families/").await
584 }
585
586 pub async fn strunz10(&self, id: i32) -> Result<serde_json::Value> {
588 self.get(&format!("/nickel-strunz-10/{}/", id)).await
589 }
590
591 pub async fn photocount(&self) -> Result<serde_json::Value> {
595 self.get("/photo-count/").await
596 }
597}
598
599#[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 pub fn new() -> Self {
610 Self {
611 token: None,
612 base_url: DEFAULT_BASE_URL.to_string(),
613 timeout: None,
614 }
615 }
616
617 pub fn token(mut self, token: impl Into<String>) -> Self {
619 self.token = Some(token.into());
620 self
621 }
622
623 pub fn base_url(mut self, url: impl Into<String>) -> Self {
625 self.base_url = url.into();
626 self
627 }
628
629 pub fn timeout(mut self, timeout: std::time::Duration) -> Self {
631 self.timeout = Some(timeout);
632 self
633 }
634
635 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}