quickchart_rs/
quickchart_client.rs

1use reqwest::{Client, Url};
2use std::path::Path;
3use thiserror::Error;
4
5#[cfg(test)]
6#[path = "quickchart_client_test.rs"]
7mod tests;
8
9const BASE_URL: &str = "https://quickchart.io";
10const USER_AGENT: &str = concat!("quickchart-rs/", env!("CARGO_PKG_VERSION"));
11const CHART_ENDPOINT: &str = "/chart";
12const CREATE_ENDPOINT: &str = "/chart/create";
13
14/// Client for interacting with the QuickChart.io API.
15///
16/// This client provides a builder-pattern API for configuring and generating charts.
17/// Use [`new()`](QuickchartClient::new) to create a new client instance, then chain
18/// builder methods to configure your chart before calling one of the generation methods.
19///
20/// # Example
21///
22/// ```
23/// use quickchart_rs::QuickchartClient;
24///
25/// let client = QuickchartClient::new()
26///     .chart(r#"{"type":"bar","data":{"labels":["A","B"],"datasets":[{"data":[1,2]}]}}"#.to_string())
27///     .width(800)
28///     .height(400);
29///
30/// let url = client.get_url().unwrap();
31/// assert!(url.starts_with("https://quickchart.io/chart"));
32/// ```
33pub struct QuickchartClient {
34    client: Client,
35    base_url: Url,
36    chart: String,
37    width: Option<usize>,
38    height: Option<usize>,
39    device_pixel_ratio: Option<f32>,
40    background_color: Option<String>,
41    version: Option<String>,
42    format: Option<String>,
43}
44
45/// Errors that can occur when using the QuickChart client.
46#[derive(Error, Debug)]
47pub enum QCError {
48    #[error("HTTP error: {0}")]
49    HttpError(#[from] reqwest::Error),
50    #[error("Failed to parse JSON response: {0}")]
51    JsonParseError(#[from] serde_json::Error),
52    #[error("Failed to parse URL: {0}")]
53    UrlParseError(#[from] url::ParseError),
54    #[error("IO error: {0}")]
55    IoError(#[from] std::io::Error),
56    #[error("Missing field in response: {0}")]
57    MissingField(String),
58}
59
60impl Default for QuickchartClient {
61    fn default() -> Self {
62        Self::new()
63    }
64}
65
66impl QuickchartClient {
67    /// Create a new QuickChart client instance.
68    pub fn new() -> Self {
69        let client = Client::builder()
70            .user_agent(USER_AGENT)
71            .build()
72            .expect("Failed to create HTTP client");
73
74        QuickchartClient {
75            client,
76            base_url: Url::parse(BASE_URL).expect("Failed to parse base URL"),
77            chart: String::new(),
78            width: None,
79            height: None,
80            device_pixel_ratio: None,
81            background_color: None,
82            version: None,
83            format: None,
84        }
85    }
86
87    /// Set the Chart.js configuration as a JSON string. Both valid JSON and JavaScript object notation are supported.
88    pub fn chart(mut self, chart: String) -> Self {
89        self.chart = chart;
90        self
91    }
92
93    pub fn width(mut self, width: usize) -> Self {
94        self.width = Some(width);
95        self
96    }
97
98    pub fn height(mut self, height: usize) -> Self {
99        self.height = Some(height);
100        self
101    }
102
103    pub fn device_pixel_ratio(mut self, dpr: f32) -> Self {
104        self.device_pixel_ratio = Some(dpr);
105        self
106    }
107
108    /// Set the background color. Supports named colors ("transparent", "white"), HEX ("#ffffff"),
109    /// RGB ("rgb(255, 0, 0)"), and HSL ("hsl(0, 100%, 50%)") formats.
110    pub fn background_color(mut self, color: String) -> Self {
111        self.background_color = Some(color);
112        self
113    }
114
115    pub fn version(mut self, version: String) -> Self {
116        self.version = Some(version);
117        self
118    }
119
120    pub fn format(mut self, format: String) -> Self {
121        self.format = Some(format);
122        self
123    }
124
125    fn parse_chart(chart: &str) -> serde_json::Value {
126        serde_json::from_str::<serde_json::Value>(chart)
127            .unwrap_or_else(|_| serde_json::Value::String(chart.to_string()))
128    }
129
130    fn build_json_body(&self) -> serde_json::Value {
131        let chart_value = Self::parse_chart(&self.chart);
132        let mut json_body = serde_json::json!({ "chart": chart_value });
133
134        if let Some(w) = self.width {
135            json_body["width"] = serde_json::Value::Number(w.into());
136        }
137        if let Some(h) = self.height {
138            json_body["height"] = serde_json::Value::Number(h.into());
139        }
140        if let Some(dpr) = self.device_pixel_ratio {
141            json_body["devicePixelRatio"] =
142                serde_json::Value::Number(serde_json::Number::from_f64(dpr as f64).unwrap());
143        }
144        if let Some(ref bkg) = self.background_color {
145            json_body["backgroundColor"] = serde_json::Value::String(bkg.clone());
146        }
147        if let Some(ref v) = self.version {
148            json_body["version"] = serde_json::Value::String(v.clone());
149        }
150        if let Some(ref f) = self.format {
151            json_body["format"] = serde_json::Value::String(f.clone());
152        }
153
154        json_body
155    }
156
157    fn compact_chart(chart: &str) -> String {
158        // Try to parse as JSON
159        if let Ok(chart_json) = serde_json::from_str::<serde_json::Value>(chart) {
160            return serde_json::to_string(&chart_json).unwrap_or_else(|_| chart.to_string());
161        }
162
163        // For non-JSON
164        chart
165            .chars()
166            .fold((String::with_capacity(chart.len()), false), |(mut acc, prev_space), ch| {
167                let is_whitespace = ch.is_whitespace();
168                if is_whitespace && !prev_space {
169                    acc.push(' ');
170                } else if !is_whitespace {
171                    acc.push(ch);
172                }
173                (acc, is_whitespace)
174            })
175            .0
176            .trim()
177            .to_string()
178    }
179
180    /// Generate a chart URL with all configured parameters as query parameters.
181    ///
182    /// # Example
183    ///
184    /// ```
185    /// use quickchart_rs::QuickchartClient;
186    ///
187    /// let client = QuickchartClient::new()
188    ///     .chart(r#"{"type":"bar","data":{"labels":["A","B"],"datasets":[{"data":[1,2]}]}}"#.to_string())
189    ///     .width(800)
190    ///     .height(400);
191    ///
192    /// let url = client.get_url().unwrap();
193    /// assert!(url.starts_with("https://quickchart.io/chart"));
194    /// assert!(url.contains("w=800"));
195    /// assert!(url.contains("h=400"));
196    /// ```
197    pub fn get_url(&self) -> Result<String, QCError> {
198        let compacted_chart = Self::compact_chart(&self.chart);
199        let mut url = self.base_url.join(CHART_ENDPOINT)?;
200
201        {
202            let mut query = url.query_pairs_mut();
203            query.append_pair("c", &compacted_chart);
204
205            if let Some(w) = self.width {
206                query.append_pair("w", &w.to_string());
207            }
208            if let Some(h) = self.height {
209                query.append_pair("h", &h.to_string());
210            }
211            if let Some(dpr) = self.device_pixel_ratio {
212                query.append_pair("devicePixelRatio", &dpr.to_string());
213            }
214            if let Some(ref bkg) = self.background_color {
215                query.append_pair("bkg", bkg);
216            }
217            if let Some(ref v) = self.version {
218                query.append_pair("v", v);
219            }
220            if let Some(ref f) = self.format {
221                query.append_pair("f", f);
222            }
223        }
224
225        Ok(url.to_string())
226    }
227
228    /// Create a short URL for the chart via POST request to `/chart/create`.
229    ///
230    /// # Example
231    ///
232    /// ```no_run
233    /// use quickchart_rs::QuickchartClient;
234    ///
235    /// let client = QuickchartClient::new()
236    ///     .chart(r#"{"type":"bar","data":{"labels":["A","B"],"datasets":[{"data":[1,2]}]}}"#.to_string())
237    ///     .get_short_url().await?;
238    /// ```
239    pub async fn get_short_url(&self) -> Result<String, QCError> {
240        let json_body = self.build_json_body();
241        let response = self
242            .send_post_request(CREATE_ENDPOINT, &json_body)
243            .await?;
244
245        let response_text = response.text().await?;
246        let response_json: serde_json::Value = serde_json::from_str(&response_text)?;
247
248        response_json
249            .get("url")
250            .and_then(|v| v.as_str())
251            .map(|url| url.trim_matches('"').trim_matches('\'').to_string())
252            .ok_or_else(|| QCError::MissingField("url".to_string()))
253    }
254
255    async fn send_post_request(
256        &self,
257        endpoint: &str,
258        json_body: &serde_json::Value,
259    ) -> Result<reqwest::Response, QCError> {
260        let response = self
261            .client
262            .post(self.base_url.join(endpoint)?.to_string())
263            .header("Content-Type", "application/json")
264            .body(serde_json::to_string(json_body)?)
265            .send()
266            .await?;
267
268        response.error_for_status().map_err(Into::into)
269    }
270
271    /// Download the chart image as bytes via POST request.
272    ///
273    /// # Example
274    ///
275    /// ```no_run
276    /// let client = QuickchartClient::new()
277    ///     .chart(r#"{"type":"bar","data":{"labels":["A","B"],"datasets":[{"data":[1,2]}]}}"#.to_string())
278    ///     .post().await?;
279    ///
280    /// ```
281    pub async fn post(&self) -> Result<Vec<u8>, QCError> {
282        let json_body = self.build_json_body();
283        let response = self.send_post_request(CHART_ENDPOINT, &json_body).await?;
284        Ok(response.bytes().await?.to_vec())
285    }
286
287    /// Download the chart image and save it directly to a file. Convenience method that combines
288    /// [`post()`](QuickchartClient::post) and file writing.
289    ///
290    /// # Example
291    ///
292    /// ```no_run
293    /// let client = QuickchartClient::new()
294    ///     .chart(r#"{"type":"bar","data":{"labels":["A","B"],"datasets":[{"data":[1,2]}]}}"#.to_string())
295    ///     .to_file("output.png")
296    ///     .await?;
297    /// ```
298    pub async fn to_file(&self, path: impl AsRef<Path>) -> Result<(), QCError> {
299        let image_bytes = self.post().await?;
300        std::fs::write(path, image_bytes)?;
301        Ok(())
302    }
303}
304