use reqwest::{Client, Url};
use std::path::Path;
use thiserror::Error;
#[cfg(test)]
#[path = "quickchart_client_test.rs"]
mod tests;
const BASE_URL: &str = "https://quickchart.io";
const USER_AGENT: &str = concat!("quickchart-rs/", env!("CARGO_PKG_VERSION"));
const CHART_ENDPOINT: &str = "/chart";
const CREATE_ENDPOINT: &str = "/chart/create";
pub struct QuickchartClient {
client: Client,
base_url: Url,
chart: String,
width: Option<usize>,
height: Option<usize>,
device_pixel_ratio: Option<f32>,
background_color: Option<String>,
version: Option<String>,
format: Option<String>,
}
#[derive(Error, Debug)]
pub enum QCError {
#[error("HTTP error: {0}")]
HttpError(#[from] reqwest::Error),
#[error("Failed to parse JSON response: {0}")]
JsonParseError(#[from] serde_json::Error),
#[error("Failed to parse URL: {0}")]
UrlParseError(#[from] url::ParseError),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("Missing field in response: {0}")]
MissingField(String),
}
impl Default for QuickchartClient {
fn default() -> Self {
Self::new()
}
}
impl QuickchartClient {
pub fn new() -> Self {
let client = Client::builder()
.user_agent(USER_AGENT)
.build()
.expect("Failed to create HTTP client");
QuickchartClient {
client,
base_url: Url::parse(BASE_URL).expect("Failed to parse base URL"),
chart: String::new(),
width: None,
height: None,
device_pixel_ratio: None,
background_color: None,
version: None,
format: None,
}
}
pub fn chart(mut self, chart: String) -> Self {
self.chart = chart;
self
}
pub fn width(mut self, width: usize) -> Self {
self.width = Some(width);
self
}
pub fn height(mut self, height: usize) -> Self {
self.height = Some(height);
self
}
pub fn device_pixel_ratio(mut self, dpr: f32) -> Self {
self.device_pixel_ratio = Some(dpr);
self
}
pub fn background_color(mut self, color: String) -> Self {
self.background_color = Some(color);
self
}
pub fn version(mut self, version: String) -> Self {
self.version = Some(version);
self
}
pub fn format(mut self, format: String) -> Self {
self.format = Some(format);
self
}
fn parse_chart(chart: &str) -> serde_json::Value {
serde_json::from_str::<serde_json::Value>(chart)
.unwrap_or_else(|_| serde_json::Value::String(chart.to_string()))
}
fn build_json_body(&self) -> serde_json::Value {
let chart_value = Self::parse_chart(&self.chart);
let mut json_body = serde_json::json!({ "chart": chart_value });
if let Some(w) = self.width {
json_body["width"] = serde_json::Value::Number(w.into());
}
if let Some(h) = self.height {
json_body["height"] = serde_json::Value::Number(h.into());
}
if let Some(dpr) = self.device_pixel_ratio {
json_body["devicePixelRatio"] =
serde_json::Value::Number(serde_json::Number::from_f64(dpr as f64).unwrap());
}
if let Some(ref bkg) = self.background_color {
json_body["backgroundColor"] = serde_json::Value::String(bkg.clone());
}
if let Some(ref v) = self.version {
json_body["version"] = serde_json::Value::String(v.clone());
}
if let Some(ref f) = self.format {
json_body["format"] = serde_json::Value::String(f.clone());
}
json_body
}
fn compact_chart(chart: &str) -> String {
if let Ok(chart_json) = serde_json::from_str::<serde_json::Value>(chart) {
return serde_json::to_string(&chart_json).unwrap_or_else(|_| chart.to_string());
}
chart
.chars()
.fold((String::with_capacity(chart.len()), false), |(mut acc, prev_space), ch| {
let is_whitespace = ch.is_whitespace();
if is_whitespace && !prev_space {
acc.push(' ');
} else if !is_whitespace {
acc.push(ch);
}
(acc, is_whitespace)
})
.0
.trim()
.to_string()
}
pub fn get_url(&self) -> Result<String, QCError> {
let compacted_chart = Self::compact_chart(&self.chart);
let mut url = self.base_url.join(CHART_ENDPOINT)?;
{
let mut query = url.query_pairs_mut();
query.append_pair("c", &compacted_chart);
if let Some(w) = self.width {
query.append_pair("w", &w.to_string());
}
if let Some(h) = self.height {
query.append_pair("h", &h.to_string());
}
if let Some(dpr) = self.device_pixel_ratio {
query.append_pair("devicePixelRatio", &dpr.to_string());
}
if let Some(ref bkg) = self.background_color {
query.append_pair("bkg", bkg);
}
if let Some(ref v) = self.version {
query.append_pair("v", v);
}
if let Some(ref f) = self.format {
query.append_pair("f", f);
}
}
Ok(url.to_string())
}
pub async fn get_short_url(&self) -> Result<String, QCError> {
let json_body = self.build_json_body();
let response = self
.send_post_request(CREATE_ENDPOINT, &json_body)
.await?;
let response_text = response.text().await?;
let response_json: serde_json::Value = serde_json::from_str(&response_text)?;
response_json
.get("url")
.and_then(|v| v.as_str())
.map(|url| url.trim_matches('"').trim_matches('\'').to_string())
.ok_or_else(|| QCError::MissingField("url".to_string()))
}
async fn send_post_request(
&self,
endpoint: &str,
json_body: &serde_json::Value,
) -> Result<reqwest::Response, QCError> {
let response = self
.client
.post(self.base_url.join(endpoint)?.to_string())
.header("Content-Type", "application/json")
.body(serde_json::to_string(json_body)?)
.send()
.await?;
response.error_for_status().map_err(Into::into)
}
pub async fn post(&self) -> Result<Vec<u8>, QCError> {
let json_body = self.build_json_body();
let response = self.send_post_request(CHART_ENDPOINT, &json_body).await?;
Ok(response.bytes().await?.to_vec())
}
pub async fn to_file(&self, path: impl AsRef<Path>) -> Result<(), QCError> {
let image_bytes = self.post().await?;
std::fs::write(path, image_bytes)?;
Ok(())
}
}