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}