Skip to main content

chartjs_image/
lib.rs

1#![allow(non_snake_case, dead_code)]
2
3//! Render Chart.JS as Image (or URL of Image)
4//!
5//! Generate [Chart.JS charts](https://www.chartjs.org/docs/latest/) as image and embed them everywhere in emails, pdf reports, chat bots...!
6//!
7//! # Features
8//!
9//! - `async` (default): Async API using tokio and reqwest
10//! - `blocking`: Blocking/synchronous API using reqwest blocking
11//! - `full`: Both async and blocking APIs
12//!
13//! # Example
14//!
15//! ```rust
16//! use chartjs_image::ChartJSImage;
17//!
18//! let url = ChartJSImage::new()
19//!     .chart(r#"{"type":"bar","data":{"labels":["Q1","Q2"],"datasets":[{"data":[1,2]}]}}"#)
20//!     .width("400")
21//!     .height("300")
22//!     .to_url();
23//!
24//! println!("{}", url);
25//! ```
26
27use std::collections::HashMap;
28use std::time::Duration;
29use thiserror::Error;
30
31/// Error type for ChartJSImage operations
32#[derive(Error, Debug)]
33#[error("{message}")]
34pub struct ChartJSImageError {
35    /// Error message
36    pub message: String,
37    /// Error code from Image-Charts API
38    pub code: Option<String>,
39    /// HTTP status code
40    pub status_code: Option<u16>,
41}
42
43impl ChartJSImageError {
44    fn new(message: impl Into<String>) -> Self {
45        Self {
46            message: message.into(),
47            code: None,
48            status_code: None,
49        }
50    }
51
52    fn with_code(mut self, code: impl Into<String>) -> Self {
53        self.code = Some(code.into());
54        self
55    }
56
57    fn with_status(mut self, status: u16) -> Self {
58        self.status_code = Some(status);
59        self
60    }
61}
62
63#[derive(Debug, Clone, serde::Deserialize)]
64struct ValidationError {
65    message: String,
66}
67
68/// Configuration for ChartJSImage client
69#[derive(Debug, Clone)]
70pub struct ChartJSImageConfig {
71    /// Protocol (http or https)
72    pub protocol: String,
73    /// API host
74    pub host: String,
75    /// API port
76    pub port: u16,
77    /// API pathname
78    pub pathname: String,
79    /// Request timeout
80    pub timeout: Duration,
81    /// Enterprise secret key for signing
82    pub secret: Option<String>,
83    /// Custom user-agent string
84    pub user_agent: Option<String>,
85}
86
87impl Default for ChartJSImageConfig {
88    fn default() -> Self {
89        Self {
90            protocol: "https".to_string(),
91            host: "image-charts.com".to_string(),
92            port: 443,
93            pathname: "/chart.js/2.8.0".to_string(),
94            timeout: Duration::from_millis(5000),
95            secret: None,
96            user_agent: None,
97        }
98    }
99}
100
101/// Builder for Chart.JS to Image API requests
102///
103/// Use the fluent API to configure chart parameters, then call one of the
104/// output methods (`to_url`, `to_buffer`, `to_file`, `to_data_uri`) to
105/// generate the chart.
106///
107/// # Example
108///
109/// ```rust
110/// use chartjs_image::ChartJSImage;
111///
112/// let chart = ChartJSImage::new()
113///     .chart(r#"{"type":"pie","data":{"datasets":[{"data":[1,2,3]}]}}"#)
114///     .width("400")
115///     .height("300")
116///     .to_url();
117/// ```
118#[derive(Debug, Clone)]
119pub struct ChartJSImage {
120    config: ChartJSImageConfig,
121    query: HashMap<String, String>,
122}
123
124impl Default for ChartJSImage {
125    fn default() -> Self {
126        Self::new()
127    }
128}
129
130impl ChartJSImage {
131    /// Create a new ChartJSImage instance with default configuration
132    ///
133    /// # Example
134    ///
135    /// ```rust
136    /// use chartjs_image::ChartJSImage;
137    ///
138    /// let chart = ChartJSImage::new();
139    /// ```
140    pub fn new() -> Self {
141        Self::with_config(ChartJSImageConfig::default())
142    }
143
144    /// Create a new ChartJSImage instance with custom configuration
145    ///
146    /// # Example
147    ///
148    /// ```rust
149    /// use chartjs_image::{ ChartJSImage, ChartJSImageConfig };
150    /// use std::time::Duration;
151    ///
152    /// let config = ChartJSImageConfig {
153    ///     timeout: Duration::from_secs(10),
154    ///     ..Default::default()
155    /// };
156    /// let chart = ChartJSImage::with_config(config);
157    /// ```
158    pub fn with_config(config: ChartJSImageConfig) -> Self {
159        Self {
160            config,
161            query: HashMap::new(),
162        }
163    }
164
165    /// Create a new ChartJSImage instance for Enterprise usage with a secret key
166    ///
167    /// # Example
168    ///
169    /// ```rust
170    /// use chartjs_image::ChartJSImage;
171    ///
172    /// let chart = ChartJSImage::with_secret("my-secret-key");
173    /// ```
174    pub fn with_secret(secret: impl Into<String>) -> Self {
175        Self::with_config(ChartJSImageConfig {
176            secret: Some(secret.into()),
177            ..Default::default()
178        })
179    }
180
181    /// Create a new ChartJSImage builder for advanced configuration
182    ///
183    /// # Example
184    ///
185    /// ```rust
186    /// use chartjs_image::ChartJSImage;
187    /// use std::time::Duration;
188    ///
189    /// let chart = ChartJSImage::builder()
190    ///     .secret("my-secret")
191    ///     .timeout(Duration::from_secs(30))
192    ///     .build();
193    /// ```
194    pub fn builder() -> ChartJSImageBuilder {
195        ChartJSImageBuilder::default()
196    }
197
198    fn clone_with(&self, key: impl Into<String>, value: impl Into<String>) -> Self {
199        let mut new_instance = self.clone();
200        new_instance.query.insert(key.into(), value.into());
201        new_instance
202    }
203
204    
205        /// Javascript/JSON definition of the chart. Use a Chart.js configuration object.
206    ///
207    /// # Examples
208    ///
209    /// ```rust
210    /// use chartjs_image::ChartJSImage;
211    /// let chart = ChartJSImage::new().c("{type:'bar',data:{labels:['Q1','Q2','Q3','Q4'],datasets:[{label:'Users',data:[50,60,70,180]},{label:'Revenue',data:[100,200,300,400]}]}}");
212    /// ```
213    pub fn c(self, value: impl Into<String>) -> Self {
214        self.clone_with("c", value)
215    }
216        /// Javascript/JSON definition of the chart. Use a Chart.js configuration object.
217    ///
218    /// # Examples
219    ///
220    /// ```rust
221    /// use chartjs_image::ChartJSImage;
222    /// let chart = ChartJSImage::new().chart("{type:'bar',data:{labels:['Q1','Q2','Q3','Q4'],datasets:[{label:'Users',data:[50,60,70,180]},{label:'Revenue',data:[100,200,300,400]}]}}");
223    /// ```
224    pub fn chart(self, value: impl Into<String>) -> Self {
225        self.clone_with("chart", value)
226    }
227        /// Width of the chart
228    ///
229    /// # Examples
230    ///
231    /// ```rust
232    /// use chartjs_image::ChartJSImage;
233    /// let chart = ChartJSImage::new().width("400");
234    /// ```
235    ///
236    /// Default: `"500"`
237    pub fn width(self, value: impl Into<String>) -> Self {
238        self.clone_with("width", value)
239    }
240        /// Height of the chart
241    ///
242    /// # Examples
243    ///
244    /// ```rust
245    /// use chartjs_image::ChartJSImage;
246    /// let chart = ChartJSImage::new().height("300");
247    /// ```
248    ///
249    /// Default: `"300"`
250    pub fn height(self, value: impl Into<String>) -> Self {
251        self.clone_with("height", value)
252    }
253        /// Background of the chart canvas. Accepts rgb (rgb(255,255,120)), colors (red), and url-encoded hex values (%23ff00ff). Abbreviated as "bkg"
254    ///
255    /// # Examples
256    ///
257    /// ```rust
258    /// use chartjs_image::ChartJSImage;
259    /// let chart = ChartJSImage::new().backgroundColor("black");
260    /// ```
261    ///
262    /// ```rust
263    /// use chartjs_image::ChartJSImage;
264    /// let chart = ChartJSImage::new().backgroundColor("rgb(255,255,120)");
265    /// ```
266    pub fn backgroundColor(self, value: impl Into<String>) -> Self {
267        self.clone_with("backgroundColor", value)
268    }
269        /// Background of the chart canvas. Accepts rgb (rgb(255,255,120)), colors (red), and url-encoded hex values (%23ff00ff). Abbreviated as "bkg"
270    ///
271    /// # Examples
272    ///
273    /// ```rust
274    /// use chartjs_image::ChartJSImage;
275    /// let chart = ChartJSImage::new().bkg("black");
276    /// ```
277    ///
278    /// ```rust
279    /// use chartjs_image::ChartJSImage;
280    /// let chart = ChartJSImage::new().bkg("rgb(255,255,120)");
281    /// ```
282    pub fn bkg(self, value: impl Into<String>) -> Self {
283        self.clone_with("bkg", value)
284    }
285        /// Encoding of your "chart" parameter. Accepted values are url and base64.
286    ///
287    /// # Examples
288    ///
289    /// ```rust
290    /// use chartjs_image::ChartJSImage;
291    /// let chart = ChartJSImage::new().encoding("url");
292    /// ```
293    ///
294    /// ```rust
295    /// use chartjs_image::ChartJSImage;
296    /// let chart = ChartJSImage::new().encoding("base64");
297    /// ```
298    ///
299    /// Default: `"url"`
300    pub fn encoding(self, value: impl Into<String>) -> Self {
301        self.clone_with("encoding", value)
302    }
303        /// image-charts enterprise `account_id`
304    ///
305    /// [Reference documentation](https://documentation.image-charts.com/enterprise/)
306    ///
307    /// # Examples
308    ///
309    /// ```rust
310    /// use chartjs_image::ChartJSImage;
311    /// let chart = ChartJSImage::new().icac("accountId");
312    /// ```
313    pub fn icac(self, value: impl Into<String>) -> Self {
314        self.clone_with("icac", value)
315    }
316        /// HMAC-SHA256 signature required to activate paid features
317    ///
318    /// [Reference documentation](https://documentation.image-charts.com/enterprise/)
319    ///
320    /// # Examples
321    ///
322    /// ```rust
323    /// use chartjs_image::ChartJSImage;
324    /// let chart = ChartJSImage::new().ichm("0785cf22a0381c2e0239e27c126de4181f501d117c2c81745611e9db928b0376");
325    /// ```
326    pub fn ichm(self, value: impl Into<String>) -> Self {
327        self.clone_with("ichm", value)
328    }
329        /// Retina is a marketing term coined by Apple that refers to devices and monitors that have a resolution and pixel density so high — roughly 300 or more pixels per inch – that a person is unable to discern the individual pixels at a normal viewing distance.
330    ///            In order to generate beautiful charts for these Retina displays, Image-Charts supports a retina mode that can be activated through the icretina=1 parameter
331    ///
332    /// [Reference documentation](https://documentation.image-charts.com/reference/retina/)
333    pub fn icretina(self, value: impl Into<String>) -> Self {
334        self.clone_with("icretina", value)
335    }
336    
337
338    /// Get the full Image-Charts API URL (signed and encoded if necessary)
339    ///
340    /// This method returns the complete URL that can be used to fetch the chart image.
341    /// If an enterprise account ID (`icac`) is set and a secret is configured,
342    /// the URL will be automatically signed with HMAC-SHA256.
343    ///
344    /// # Example
345    ///
346    /// ```rust
347    /// use chartjs_image::ChartJSImage;
348    ///
349    /// let url = ChartJSImage::new()
350    ///     .chart(r#"{"type":"pie","data":{"datasets":[{"data":[1,2]}]}}"#)
351    ///     .width("100")
352    ///     .height("100")
353    ///     .to_url();
354    ///
355    /// assert!(url.starts_with("https://image-charts.com/chart.js/2.8.0?"));
356    /// ```
357    pub fn to_url(&self) -> String {
358        let mut pairs: Vec<(&String, &String)> = self.query.iter().collect();
359        pairs.sort_by(|a, b| a.0.cmp(b.0));
360
361        let mut query_string = pairs
362            .iter()
363            .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
364            .collect::<Vec<_>>()
365            .join("&");
366
367        if self.query.contains_key("icac") {
368            if let Some(ref secret) = self.config.secret {
369                if !secret.is_empty() {
370                    let signature = self.sign(&query_string, secret);
371                    query_string.push_str(&format!("&ichm={}", signature));
372                }
373            }
374        }
375
376        // Only include port if it's not the default for the protocol
377        let port_str = match (self.config.protocol.as_str(), self.config.port) {
378            ("https", 443) | ("http", 80) => String::new(),
379            (_, port) => format!(":{}", port),
380        };
381
382        format!(
383            "{}://{}{}{}?{}",
384            self.config.protocol,
385            self.config.host,
386            port_str,
387            self.config.pathname,
388            query_string
389        )
390    }
391
392    fn sign(&self, data: &str, secret: &str) -> String {
393        use hmac::{Hmac, Mac};
394        use sha2::Sha256;
395
396        type HmacSha256 = Hmac<Sha256>;
397        let mut mac =
398            HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size");
399        mac.update(data.as_bytes());
400        let result = mac.finalize();
401        hex::encode(result.into_bytes())
402    }
403
404    fn get_mime_type(&self) -> &str {
405        // ChartJSImage always outputs PNG (no GIF animation support)
406        "image/png"
407    }
408
409    fn get_file_format(&self) -> &str {
410        // ChartJSImage always outputs PNG (no GIF animation support)
411        "png"
412    }
413
414    fn build_user_agent(&self) -> String {
415        let default_ua = format!(
416            "rust-chartjs_image/{}{}",
417            env!("CARGO_PKG_VERSION"),
418            self.query
419                .get("icac")
420                .map(|icac| format!(" ({})", icac))
421                .unwrap_or_default()
422        );
423        self.config.user_agent.clone().unwrap_or(default_ua)
424    }
425
426    fn parse_error_response(
427        status: u16,
428        error_code: Option<String>,
429        validation_header: Option<&str>,
430    ) -> ChartJSImageError {
431        let validation_message = validation_header
432            .and_then(|v| serde_json::from_str::<Vec<ValidationError>>(v).ok())
433            .map(|errors| {
434                errors
435                    .into_iter()
436                    .map(|e| e.message)
437                    .collect::<Vec<_>>()
438                    .join("\n")
439            });
440
441        let message = validation_message
442            .or_else(|| error_code.clone())
443            .unwrap_or_else(|| format!("HTTP {}", status));
444
445        let mut err = ChartJSImageError::new(message).with_status(status);
446        if let Some(code) = error_code {
447            err = err.with_code(code);
448        }
449        err
450    }
451}
452
453// Async implementation
454#[cfg(feature = "async")]
455impl ChartJSImage {
456    /// Do an async request to Image-Charts API and return the image as bytes
457    ///
458    /// # Example
459    ///
460    /// ```rust,no_run
461    /// use chartjs_image::ChartJSImage;
462    ///
463    /// #[tokio::main]
464    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
465    ///     let buffer = ChartJSImage::new()
466    ///         .chart(r#"{"type":"pie","data":{"datasets":[{"data":[1,2]}]}}"#)
467    ///         .width("100")
468    ///         .height("100")
469    ///         .to_buffer()
470    ///         .await?;
471    ///
472    ///     println!("Image size: {} bytes", buffer.len());
473    ///     Ok(())
474    /// }
475    /// ```
476    pub async fn to_buffer(&self) -> Result<Vec<u8>, ChartJSImageError> {
477        let client = reqwest::Client::builder()
478            .timeout(self.config.timeout)
479            .build()
480            .map_err(|e| ChartJSImageError::new(e.to_string()))?;
481
482        let response = client
483            .get(self.to_url())
484            .header("User-Agent", self.build_user_agent())
485            .send()
486            .await
487            .map_err(|e| {
488                let mut err = ChartJSImageError::new(e.to_string());
489                if let Some(status) = e.status() {
490                    err = err.with_status(status.as_u16());
491                }
492                err
493            })?;
494
495        let status = response.status().as_u16();
496        if (200..300).contains(&status) {
497            response
498                .bytes()
499                .await
500                .map(|b| b.to_vec())
501                .map_err(|e| ChartJSImageError::new(e.to_string()).with_status(status))
502        } else {
503            let error_code = response
504                .headers()
505                .get("x-ic-error-code")
506                .and_then(|v| v.to_str().ok())
507                .map(String::from);
508            let validation_header = response
509                .headers()
510                .get("x-ic-error-validation")
511                .and_then(|v| v.to_str().ok())
512                .map(String::from);
513
514            Err(Self::parse_error_response(
515                status,
516                error_code,
517                validation_header.as_deref(),
518            ))
519        }
520    }
521
522    /// Do an async request and write the image to a file
523    ///
524    /// # Example
525    ///
526    /// ```rust,no_run
527    /// use chartjs_image::ChartJSImage;
528    ///
529    /// #[tokio::main]
530    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
531    ///     ChartJSImage::new()
532    ///         .chart(r#"{"type":"pie","data":{"datasets":[{"data":[1,2]}]}}"#)
533    ///         .width("100")
534    ///         .height("100")
535    ///         .to_file("chart.png")
536    ///         .await?;
537    ///
538    ///     println!("Chart saved to chart.png");
539    ///     Ok(())
540    /// }
541    /// ```
542    pub async fn to_file(&self, path: impl AsRef<std::path::Path>) -> Result<(), ChartJSImageError> {
543        let buffer = self.to_buffer().await?;
544        tokio::fs::write(path, buffer)
545            .await
546            .map_err(|e| ChartJSImageError::new(e.to_string()))
547    }
548
549    /// Do an async request and return a base64-encoded data URI
550    ///
551    /// The returned string can be used directly in HTML `<img>` tags or CSS.
552    ///
553    /// # Example
554    ///
555    /// ```rust,no_run
556    /// use chartjs_image::ChartJSImage;
557    ///
558    /// #[tokio::main]
559    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
560    ///     let data_uri = ChartJSImage::new()
561    ///         .chart(r#"{"type":"pie","data":{"datasets":[{"data":[1,2]}]}}"#)
562    ///         .width("100")
563    ///         .height("100")
564    ///         .to_data_uri()
565    ///         .await?;
566    ///
567    ///     println!("<img src=\"{}\" />", data_uri);
568    ///     Ok(())
569    /// }
570    /// ```
571    pub async fn to_data_uri(&self) -> Result<String, ChartJSImageError> {
572        use base64::{engine::general_purpose::STANDARD, Engine as _};
573        let buffer = self.to_buffer().await?;
574        let encoded = STANDARD.encode(&buffer);
575        Ok(format!("data:{};base64,{}", self.get_mime_type(), encoded))
576    }
577}
578
579// Blocking implementation
580#[cfg(feature = "blocking")]
581impl ChartJSImage {
582    /// Do a blocking request to Image-Charts API and return the image as bytes
583    ///
584    /// # Example
585    ///
586    /// ```rust,no_run
587    /// use chartjs_image::ChartJSImage;
588    ///
589    /// fn main() -> Result<(), Box<dyn std::error::Error>> {
590    ///     let buffer = ChartJSImage::new()
591    ///         .chart(r#"{"type":"pie","data":{"datasets":[{"data":[1,2]}]}}"#)
592    ///         .width("100")
593    ///         .height("100")
594    ///         .to_buffer_blocking()?;
595    ///
596    ///     println!("Image size: {} bytes", buffer.len());
597    ///     Ok(())
598    /// }
599    /// ```
600    pub fn to_buffer_blocking(&self) -> Result<Vec<u8>, ChartJSImageError> {
601        let client = reqwest::blocking::Client::builder()
602            .timeout(self.config.timeout)
603            .build()
604            .map_err(|e| ChartJSImageError::new(e.to_string()))?;
605
606        let response = client
607            .get(self.to_url())
608            .header("User-Agent", self.build_user_agent())
609            .send()
610            .map_err(|e| {
611                let mut err = ChartJSImageError::new(e.to_string());
612                if let Some(status) = e.status() {
613                    err = err.with_status(status.as_u16());
614                }
615                err
616            })?;
617
618        let status = response.status().as_u16();
619        if (200..300).contains(&status) {
620            response
621                .bytes()
622                .map(|b| b.to_vec())
623                .map_err(|e| ChartJSImageError::new(e.to_string()).with_status(status))
624        } else {
625            let error_code = response
626                .headers()
627                .get("x-ic-error-code")
628                .and_then(|v| v.to_str().ok())
629                .map(String::from);
630            let validation_header = response
631                .headers()
632                .get("x-ic-error-validation")
633                .and_then(|v| v.to_str().ok())
634                .map(String::from);
635
636            Err(Self::parse_error_response(
637                status,
638                error_code,
639                validation_header.as_deref(),
640            ))
641        }
642    }
643
644    /// Do a blocking request and write the image to a file
645    ///
646    /// # Example
647    ///
648    /// ```rust,no_run
649    /// use chartjs_image::ChartJSImage;
650    ///
651    /// fn main() -> Result<(), Box<dyn std::error::Error>> {
652    ///     ChartJSImage::new()
653    ///         .chart(r#"{"type":"pie","data":{"datasets":[{"data":[1,2]}]}}"#)
654    ///         .width("100")
655    ///         .height("100")
656    ///         .to_file_blocking("chart.png")?;
657    ///
658    ///     println!("Chart saved to chart.png");
659    ///     Ok(())
660    /// }
661    /// ```
662    pub fn to_file_blocking(
663        &self,
664        path: impl AsRef<std::path::Path>,
665    ) -> Result<(), ChartJSImageError> {
666        let buffer = self.to_buffer_blocking()?;
667        std::fs::write(path, buffer).map_err(|e| ChartJSImageError::new(e.to_string()))
668    }
669
670    /// Do a blocking request and return a base64-encoded data URI
671    ///
672    /// # Example
673    ///
674    /// ```rust,no_run
675    /// use chartjs_image::ChartJSImage;
676    ///
677    /// fn main() -> Result<(), Box<dyn std::error::Error>> {
678    ///     let data_uri = ChartJSImage::new()
679    ///         .chart(r#"{"type":"pie","data":{"datasets":[{"data":[1,2]}]}}"#)
680    ///         .width("100")
681    ///         .height("100")
682    ///         .to_data_uri_blocking()?;
683    ///
684    ///     println!("<img src=\"{}\" />", data_uri);
685    ///     Ok(())
686    /// }
687    /// ```
688    pub fn to_data_uri_blocking(&self) -> Result<String, ChartJSImageError> {
689        use base64::{engine::general_purpose::STANDARD, Engine as _};
690        let buffer = self.to_buffer_blocking()?;
691        let encoded = STANDARD.encode(&buffer);
692        Ok(format!("data:{};base64,{}", self.get_mime_type(), encoded))
693    }
694}
695
696/// Builder for ChartJSImageConfig
697///
698/// Provides a fluent API to configure ChartJSImage instances.
699///
700/// # Example
701///
702/// ```rust
703/// use chartjs_image::ChartJSImage;
704/// use std::time::Duration;
705///
706/// let chart = ChartJSImage::builder()
707///     .secret("my-secret-key")
708///     .timeout(Duration::from_secs(30))
709///     .host("custom.image-charts.com")
710///     .build()
711///     .chart(r#"{"type":"bar","data":{}}"#)
712///     .width("400")
713///     .height("300");
714/// ```
715#[derive(Debug, Default)]
716pub struct ChartJSImageBuilder {
717    protocol: Option<String>,
718    host: Option<String>,
719    port: Option<u16>,
720    pathname: Option<String>,
721    timeout: Option<Duration>,
722    secret: Option<String>,
723    user_agent: Option<String>,
724}
725
726impl ChartJSImageBuilder {
727    /// Set the protocol (http or https)
728    pub fn protocol(mut self, protocol: impl Into<String>) -> Self {
729        self.protocol = Some(protocol.into());
730        self
731    }
732
733    /// Set the API host
734    pub fn host(mut self, host: impl Into<String>) -> Self {
735        self.host = Some(host.into());
736        self
737    }
738
739    /// Set the API port
740    pub fn port(mut self, port: u16) -> Self {
741        self.port = Some(port);
742        self
743    }
744
745    /// Set the API pathname
746    pub fn pathname(mut self, pathname: impl Into<String>) -> Self {
747        self.pathname = Some(pathname.into());
748        self
749    }
750
751    /// Set the request timeout
752    pub fn timeout(mut self, timeout: Duration) -> Self {
753        self.timeout = Some(timeout);
754        self
755    }
756
757    /// Set the enterprise secret key for URL signing
758    pub fn secret(mut self, secret: impl Into<String>) -> Self {
759        self.secret = Some(secret.into());
760        self
761    }
762
763    /// Set a custom user-agent string
764    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
765        self.user_agent = Some(user_agent.into());
766        self
767    }
768
769    /// Build the ChartJSImage instance
770    pub fn build(self) -> ChartJSImage {
771        let default = ChartJSImageConfig::default();
772        ChartJSImage::with_config(ChartJSImageConfig {
773            protocol: self.protocol.unwrap_or(default.protocol),
774            host: self.host.unwrap_or(default.host),
775            port: self.port.unwrap_or(default.port),
776            pathname: self.pathname.unwrap_or(default.pathname),
777            timeout: self.timeout.unwrap_or(default.timeout),
778            secret: self.secret,
779            user_agent: self.user_agent,
780        })
781    }
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787
788    fn create_chartjs_image() -> ChartJSImage {
789        match std::env::var("IMAGE_CHARTS_USER_AGENT") {
790            Ok(ua) => ChartJSImage::builder().user_agent(ua).build(),
791            Err(_) => ChartJSImage::new(),
792        }
793    }
794
795    fn create_chartjs_image_with_secret(secret: &str) -> ChartJSImage {
796        match std::env::var("IMAGE_CHARTS_USER_AGENT") {
797            Ok(ua) => ChartJSImage::builder().secret(secret).user_agent(ua).build(),
798            Err(_) => ChartJSImage::with_secret(secret),
799        }
800    }
801
802    #[test]
803    fn test_to_url_basic() {
804        let url = ChartJSImage::new()
805            .chart(r#"{"type":"pie"}"#)
806            .width("100")
807            .height("100")
808            .to_url();
809        assert!(url.contains("chart="));
810        assert!(url.contains("width=100"));
811        assert!(url.contains("height=100"));
812    }
813
814    #[test]
815    fn test_to_url_includes_protocol_host() {
816        let url = ChartJSImage::new().chart("{}").to_url();
817        // Default port (443 for https) should not be included in the URL
818        assert!(url.starts_with("https://image-charts.com/chart.js/2.8.0?"));
819    }
820
821    #[test]
822    fn test_to_url_includes_custom_port() {
823        let config = ChartJSImageConfig {
824            port: 8080,
825            ..Default::default()
826        };
827        let url = ChartJSImage::with_config(config).chart("{}").to_url();
828        // Non-default port should be included in the URL
829        assert!(url.starts_with("https://image-charts.com:8080/chart.js/2.8.0?"));
830    }
831
832    #[test]
833    fn test_to_url_with_signature() {
834        let url = ChartJSImage::with_secret("plop")
835            .chart("{}")
836            .width("100")
837            .height("100")
838            .icac("test_fixture")
839            .to_url();
840        assert!(url.contains("ichm="));
841    }
842
843    #[test]
844    fn test_default_config() {
845        let config = ChartJSImageConfig::default();
846        assert_eq!(config.protocol, "https");
847        assert_eq!(config.host, "image-charts.com");
848        assert_eq!(config.port, 443);
849        assert_eq!(config.pathname, "/chart.js/2.8.0");
850        assert_eq!(config.timeout, Duration::from_millis(5000));
851    }
852
853    #[test]
854    fn test_builder_pattern() {
855        let chart = ChartJSImage::builder()
856            .secret("test-secret")
857            .timeout(Duration::from_secs(10))
858            .host("custom.host.com")
859            .build();
860
861        assert_eq!(chart.config.host, "custom.host.com");
862        assert_eq!(chart.config.timeout, Duration::from_secs(10));
863        assert_eq!(chart.config.secret, Some("test-secret".to_string()));
864    }
865
866    #[test]
867    fn test_get_mime_type_png() {
868        // ChartJSImage always outputs PNG (no GIF animation support)
869        let chart = ChartJSImage::new().chart("{}").width("100").height("100");
870        assert_eq!(chart.get_mime_type(), "image/png");
871    }
872
873    #[cfg(feature = "blocking")]
874    mod blocking_tests {
875        use super::*;
876
877        #[test]
878        fn test_to_buffer_blocking_works() {
879            std::thread::sleep(std::time::Duration::from_secs(3));
880
881            let result = create_chartjs_image()
882                .chart(r#"{"type":"pie","data":{"datasets":[{"data":[1,2,3]}]}}"#)
883                .width("100")
884                .height("100")
885                .to_buffer_blocking();
886            assert!(result.is_ok());
887            let buffer = result.unwrap();
888            assert!(!buffer.is_empty());
889        }
890
891        #[test]
892        fn test_to_data_uri_blocking_works() {
893            std::thread::sleep(std::time::Duration::from_secs(3));
894
895            let result = create_chartjs_image()
896                .chart(r#"{"type":"pie","data":{"datasets":[{"data":[1,2,3]}]}}"#)
897                .width("100")
898                .height("100")
899                .to_data_uri_blocking();
900            assert!(result.is_ok());
901            let data_uri = result.unwrap();
902            assert!(data_uri.starts_with("data:image/png;base64,"));
903        }
904    }
905
906    #[cfg(feature = "async")]
907    mod async_tests {
908        use super::*;
909
910        #[tokio::test]
911        async fn test_to_buffer_async_works() {
912            tokio::time::sleep(std::time::Duration::from_secs(3)).await;
913
914            let result = create_chartjs_image()
915                .chart(r#"{"type":"pie","data":{"datasets":[{"data":[1,2,3]}]}}"#)
916                .width("100")
917                .height("100")
918                .to_buffer()
919                .await;
920            assert!(result.is_ok());
921            let buffer = result.unwrap();
922            assert!(!buffer.is_empty());
923        }
924
925        #[tokio::test]
926        async fn test_to_data_uri_async_works() {
927            tokio::time::sleep(std::time::Duration::from_secs(3)).await;
928
929            let result = create_chartjs_image()
930                .chart(r#"{"type":"pie","data":{"datasets":[{"data":[1,2,3]}]}}"#)
931                .width("100")
932                .height("100")
933                .to_data_uri()
934                .await;
935            assert!(result.is_ok());
936            let data_uri = result.unwrap();
937            assert!(data_uri.starts_with("data:image/png;base64,"));
938        }
939    }
940}