dioxus_iconify/
api.rs

1use anyhow::{Context, Result, anyhow};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5const API_BASE_URL: &str = "https://api.iconify.design";
6
7/// Icon data returned from the Iconify API
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct IconifyIcon {
10    pub body: String,
11    #[serde(default)]
12    pub width: Option<u32>,
13    #[serde(default)]
14    pub height: Option<u32>,
15    #[serde(default, rename = "viewBox")]
16    pub view_box: Option<String>,
17}
18
19/// Collection information from Iconify API
20/// Based on IconifyInfo: https://iconify.design/docs/types/iconify-info.html
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct IconifyCollectionInfo {
23    #[serde(default)]
24    pub name: Option<String>,
25    #[serde(default)]
26    pub author: Option<IconifyAuthor>,
27    #[serde(default)]
28    pub license: Option<IconifyLicense>,
29    #[serde(default)]
30    pub height: Option<u32>,
31    #[serde(default)]
32    pub category: Option<String>,
33    #[serde(default)]
34    pub palette: Option<bool>,
35    #[serde(default)]
36    pub total: Option<u32>,
37}
38
39/// Author information in collection metadata
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(untagged)]
42pub enum IconifyAuthor {
43    Simple(String),
44    Detailed {
45        #[serde(default)]
46        name: Option<String>,
47        #[serde(default)]
48        url: Option<String>,
49    },
50}
51
52/// License information in collection metadata
53#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(untagged)]
55pub enum IconifyLicense {
56    Simple(String),
57    Detailed {
58        #[serde(default)]
59        title: Option<String>,
60        #[serde(default)]
61        spdx: Option<String>,
62        #[serde(default)]
63        url: Option<String>,
64    },
65}
66
67/// API response structure for icon data
68#[derive(Debug, Deserialize)]
69struct IconifyApiResponse {
70    // prefix: String,
71    icons: HashMap<String, IconifyIcon>,
72    #[serde(default)]
73    width: Option<u32>,
74    #[serde(default)]
75    height: Option<u32>,
76}
77
78/// Iconify API client
79pub struct IconifyClient {
80    client: reqwest::Client,
81    base_url: String,
82}
83
84impl IconifyClient {
85    /// Create a new Iconify API client
86    pub fn new() -> Result<Self> {
87        let client = reqwest::Client::builder()
88            .timeout(std::time::Duration::from_secs(30))
89            .build()
90            .context("Failed to create HTTP client")?;
91
92        Ok(Self {
93            client,
94            base_url: API_BASE_URL.to_string(),
95        })
96    }
97
98    /// Fetch collection information from the Iconify API
99    pub async fn fetch_collection_info(&self, collection: &str) -> Result<IconifyCollectionInfo> {
100        let url = format!(
101            "{}/collection?prefix={}&info=true",
102            self.base_url, collection
103        );
104
105        let response = self.client.get(&url).send().await.context(format!(
106            "Failed to fetch collection info for '{}'",
107            collection
108        ))?;
109
110        if !response.status().is_success() {
111            let status = response.status();
112            let text = response.text().await.unwrap_or_default();
113            return Err(anyhow!(
114                "API request failed with status {}: {}",
115                status,
116                text
117            ));
118        }
119
120        let collection_info: IconifyCollectionInfo = response
121            .json()
122            .await
123            .context("Failed to parse collection info response")?;
124
125        Ok(collection_info)
126    }
127
128    /// Fetch a single icon from the Iconify API
129    pub async fn fetch_icon(&self, collection: &str, icon_name: &str) -> Result<IconifyIcon> {
130        let url = format!("{}/{}.json?icons={}", self.base_url, collection, icon_name);
131
132        let response = self
133            .client
134            .get(&url)
135            .send()
136            .await
137            .context(format!("Failed to fetch icon {}:{}", collection, icon_name))?;
138
139        if !response.status().is_success() {
140            let status = response.status();
141            let text = response.text().await.unwrap_or_default();
142            return Err(anyhow!(
143                "API request failed with status {}: {}",
144                status,
145                text
146            ));
147        }
148
149        let api_response: IconifyApiResponse = response
150            .json()
151            .await
152            .context("Failed to parse API response")?;
153
154        let icon = api_response
155            .icons
156            .get(icon_name)
157            .ok_or_else(|| {
158                anyhow!(
159                    "Icon '{}' not found in collection '{}'",
160                    icon_name,
161                    collection
162                )
163            })?
164            .clone();
165
166        // Use icon-specific dimensions or fall back to collection defaults
167        let width = icon.width.or(api_response.width).unwrap_or(24);
168        let height = icon.height.or(api_response.height).unwrap_or(24);
169
170        // Generate viewBox if not provided
171        let view_box = icon
172            .view_box
173            .clone()
174            .unwrap_or_else(|| format!("0 0 {} {}", width, height));
175
176        Ok(IconifyIcon {
177            body: icon.body,
178            width: Some(width),
179            height: Some(height),
180            view_box: Some(view_box),
181        })
182    }
183
184    // /// Fetch multiple icons from the same collection
185    // pub fn fetch_icons(
186    //     &self,
187    //     collection: &str,
188    //     icon_names: &[String],
189    // ) -> Result<HashMap<String, IconifyIcon>> {
190    //     if icon_names.is_empty() {
191    //         return Ok(HashMap::new());
192    //     }
193
194    //     let icons_param = icon_names.join(",");
195    //     let url = format!(
196    //         "{}/{}.json?icons={}",
197    //         self.base_url, collection, icons_param
198    //     );
199
200    //     let response = self.client.get(&url).send().context(format!(
201    //         "Failed to fetch icons from collection '{}'",
202    //         collection
203    //     ))?;
204
205    //     if !response.status().is_success() {
206    //         return Err(anyhow!(
207    //             "API request failed with status {}: {}",
208    //             response.status(),
209    //             response.text().unwrap_or_default()
210    //         ));
211    //     }
212
213    //     let api_response: IconifyApiResponse =
214    //         response.json().context("Failed to parse API response")?;
215
216    //     let default_width = api_response.width.unwrap_or(24);
217    //     let default_height = api_response.height.unwrap_or(24);
218
219    //     // Process each icon and ensure they have dimensions
220    //     let mut result = HashMap::new();
221    //     for (name, mut icon) in api_response.icons {
222    //         let width = icon.width.unwrap_or(default_width);
223    //         let height = icon.height.unwrap_or(default_height);
224
225    //         icon.width = Some(width);
226    //         icon.height = Some(height);
227    //         icon.view_box = Some(
228    //             icon.view_box
229    //                 .clone()
230    //                 .unwrap_or_else(|| format!("0 0 {} {}", width, height)),
231    //         );
232
233    //         result.insert(name, icon);
234    //     }
235
236    //     Ok(result)
237    // }
238}
239
240impl Default for IconifyClient {
241    fn default() -> Self {
242        Self::new().expect("Failed to create Iconify API client")
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use rstest::rstest;
250
251    #[rstest]
252    #[case("mdi", "home")]
253    #[case("heroicons", "arrow-left")]
254    #[case("lucide", "settings")]
255    #[ignore] // Requires internet connection
256    #[tokio::test]
257    async fn test_fetch_icon(#[case] collection: &str, #[case] icon_name: &str) {
258        let client = IconifyClient::new().unwrap();
259        let icon = client.fetch_icon(collection, icon_name).await.unwrap();
260
261        assert!(!icon.body.is_empty());
262        assert!(icon.width.is_some());
263        assert!(icon.height.is_some());
264        assert!(icon.view_box.is_some());
265    }
266
267    #[tokio::test]
268    #[ignore] // Requires internet connection
269    async fn test_fetch_nonexistent_icon() {
270        let client = IconifyClient::new().unwrap();
271        let result = client
272            .fetch_icon("mdi", "this-icon-does-not-exist-12345")
273            .await;
274
275        assert!(result.is_err());
276    }
277
278    #[rstest]
279    #[case("mdi")]
280    #[case("heroicons")]
281    #[case("lucide")]
282    #[ignore] // Requires internet connection
283    #[tokio::test]
284    async fn test_fetch_collection_info(#[case] collection: &str) {
285        let client = IconifyClient::new().unwrap();
286        let info = client.fetch_collection_info(collection).await.unwrap();
287
288        // At minimum, we should get a name
289        assert!(info.name.is_some(), "Collection should have a name");
290    }
291}