quickchart_rs/
quickchart_client.rs1use 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
14pub 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#[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 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 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 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 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 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 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 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 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 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