html2pdf_api/service/types.rs
1//! Shared types for the PDF generation service.
2//!
3//! This module provides framework-agnostic types used across all integrations
4//! (Actix-web, Rocket, Axum). These types define the API contract for PDF
5//! generation endpoints.
6//!
7//! # Overview
8//!
9//! | Type | Purpose |
10//! |------|---------|
11//! | [`PdfFromUrlRequest`] | Parameters for URL-to-PDF conversion |
12//! | [`PdfFromHtmlRequest`] | Parameters for HTML-to-PDF conversion |
13//! | [`PdfResponse`] | Successful PDF generation result |
14//! | [`PdfServiceError`] | Error types with HTTP status mapping |
15//! | [`ErrorResponse`] | JSON error response for API clients |
16//! | [`PoolStatsResponse`] | Browser pool statistics |
17//! | [`HealthResponse`] | Health check response |
18//!
19//! # Usage
20//!
21//! These types are used internally by framework integrations, but you can also
22//! use them directly for custom handlers:
23//!
24//! ```rust,ignore
25//! use html2pdf_api::service::{PdfFromUrlRequest, generate_pdf_from_url};
26//!
27//! let request = PdfFromUrlRequest {
28//! url: "https://example.com".to_string(),
29//! filename: Some("report.pdf".to_string()),
30//! landscape: Some(true),
31//! ..Default::default()
32//! };
33//!
34//! // In a blocking context
35//! let response = generate_pdf_from_url(&pool, &request)?;
36//! println!("Generated {} bytes", response.data.len());
37//! ```
38//!
39//! # Error Handling
40//!
41//! All errors are represented by [`PdfServiceError`], which provides:
42//! - Human-readable error messages via [`Display`](std::fmt::Display)
43//! - HTTP status codes via [`status_code()`](PdfServiceError::status_code)
44//! - Machine-readable error codes via [`error_code()`](PdfServiceError::error_code)
45//!
46//! ```rust,ignore
47//! use html2pdf_api::service::{PdfServiceError, ErrorResponse};
48//!
49//! fn handle_error(err: PdfServiceError) -> (u16, ErrorResponse) {
50//! let status = err.status_code();
51//! let body = ErrorResponse::from(err);
52//! (status, body)
53//! }
54//! ```
55
56use serde::{Deserialize, Serialize};
57use std::time::Duration;
58
59// ============================================================================
60// Request Types
61// ============================================================================
62
63/// Request parameters for converting a URL to PDF.
64///
65/// This struct represents the query parameters or request body for the
66/// URL-to-PDF endpoint. All fields except `url` are optional with sensible
67/// defaults.
68///
69/// # Required Fields
70///
71/// | Field | Type | Description |
72/// |-------|------|-------------|
73/// | `url` | `String` | The URL to convert (must be a valid HTTP/HTTPS URL) |
74///
75/// # Optional Fields
76///
77/// | Field | Type | Default | Description |
78/// |-------|------|---------|-------------|
79/// | `filename` | `Option<String>` | `"document.pdf"` | Output filename for Content-Disposition header |
80/// | `waitsecs` | `Option<u64>` | `5` | Seconds to wait for JavaScript execution |
81/// | `landscape` | `Option<bool>` | `false` | Use landscape page orientation |
82/// | `download` | `Option<bool>` | `false` | Force download vs inline display |
83/// | `print_background` | `Option<bool>` | `true` | Include background colors/images |
84///
85/// # JavaScript Wait Behavior
86///
87/// The `waitsecs` parameter controls how long to wait for JavaScript to complete.
88/// The service polls for `window.isPageDone === true` every 200ms. If your page
89/// sets this flag, rendering completes immediately; otherwise, it waits the full
90/// duration.
91///
92/// ```javascript
93/// // In your web page, signal when rendering is complete:
94/// window.isPageDone = true;
95/// ```
96///
97/// # Examples
98///
99/// ## Basic URL conversion
100///
101/// ```rust
102/// use html2pdf_api::service::PdfFromUrlRequest;
103///
104/// let request = PdfFromUrlRequest {
105/// url: "https://example.com".to_string(),
106/// ..Default::default()
107/// };
108///
109/// assert_eq!(request.filename_or_default(), "document.pdf");
110/// assert_eq!(request.wait_duration().as_secs(), 5);
111/// ```
112///
113/// ## Landscape PDF with custom filename
114///
115/// ```rust
116/// use html2pdf_api::service::PdfFromUrlRequest;
117///
118/// let request = PdfFromUrlRequest {
119/// url: "https://example.com/report".to_string(),
120/// filename: Some("quarterly-report.pdf".to_string()),
121/// landscape: Some(true),
122/// waitsecs: Some(10), // Complex charts need more time
123/// ..Default::default()
124/// };
125///
126/// assert!(request.is_landscape());
127/// assert_eq!(request.wait_duration().as_secs(), 10);
128/// ```
129///
130/// ## Force download (vs inline display)
131///
132/// ```rust
133/// use html2pdf_api::service::PdfFromUrlRequest;
134///
135/// let request = PdfFromUrlRequest {
136/// url: "https://example.com/invoice".to_string(),
137/// filename: Some("invoice-2024.pdf".to_string()),
138/// download: Some(true), // Forces "Content-Disposition: attachment"
139/// ..Default::default()
140/// };
141///
142/// assert!(request.is_download());
143/// ```
144///
145/// # HTTP API Usage
146///
147/// ## As Query Parameters (GET request)
148///
149/// ```text
150/// GET /pdf?url=https://example.com&filename=report.pdf&landscape=true&waitsecs=10
151/// ```
152///
153/// ## As JSON Body (POST request, if supported)
154///
155/// ```json
156/// {
157/// "url": "https://example.com",
158/// "filename": "report.pdf",
159/// "landscape": true,
160/// "waitsecs": 10,
161/// "download": false,
162/// "print_background": true
163/// }
164/// ```
165#[derive(Debug, Clone, Serialize, Deserialize, Default)]
166pub struct PdfFromUrlRequest {
167 /// The URL to convert to PDF.
168 ///
169 /// Must be a valid HTTP or HTTPS URL. Relative URLs are not supported.
170 ///
171 /// # Validation
172 ///
173 /// The URL is validated using the `url` crate before processing.
174 /// Invalid URLs result in a [`PdfServiceError::InvalidUrl`] error.
175 ///
176 /// # Examples
177 ///
178 /// Valid URLs:
179 /// - `https://example.com`
180 /// - `https://example.com/path?query=value`
181 /// - `http://localhost:3000/report`
182 ///
183 /// Invalid URLs:
184 /// - `example.com` (missing scheme)
185 /// - `/path/to/page` (relative URL)
186 /// - `` (empty string)
187 pub url: String,
188
189 /// Output filename for the generated PDF.
190 ///
191 /// This value is used in the `Content-Disposition` header. If not provided,
192 /// defaults to `"document.pdf"`.
193 ///
194 /// # Notes
195 ///
196 /// - The filename should include the `.pdf` extension
197 /// - Special characters are not escaped; ensure valid filename characters
198 /// - The browser may modify the filename based on its own rules
199 #[serde(default)]
200 pub filename: Option<String>,
201
202 /// Seconds to wait for JavaScript execution before generating the PDF.
203 ///
204 /// Many modern web pages rely heavily on JavaScript for rendering content.
205 /// This parameter controls the maximum wait time for the page to become ready.
206 ///
207 /// # Behavior
208 ///
209 /// 1. After navigation completes, the service waits up to `waitsecs` seconds
210 /// 2. Every 200ms, it checks if `window.isPageDone === true`
211 /// 3. If the flag is set, PDF generation begins immediately
212 /// 4. If timeout is reached, PDF generation proceeds anyway
213 ///
214 /// # Default
215 ///
216 /// `5` seconds - suitable for most pages with moderate JavaScript.
217 ///
218 /// # Recommendations
219 ///
220 /// | Page Type | Recommended Value |
221 /// |-----------|-------------------|
222 /// | Static HTML | `1-2` |
223 /// | Light JavaScript | `3-5` |
224 /// | Heavy SPA (React, Vue) | `5-10` |
225 /// | Complex charts/graphs | `10-15` |
226 #[serde(default)]
227 pub waitsecs: Option<u64>,
228
229 /// Use landscape page orientation.
230 ///
231 /// When `true`, the PDF is generated in landscape mode (wider than tall).
232 /// When `false` or not specified, portrait mode is used.
233 ///
234 /// # Default
235 ///
236 /// `false` (portrait orientation)
237 ///
238 /// # Use Cases
239 ///
240 /// - Wide tables or spreadsheets
241 /// - Horizontal charts and graphs
242 /// - Timeline visualizations
243 /// - Presentation slides
244 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub landscape: Option<bool>,
246
247 /// Force download instead of inline display.
248 ///
249 /// Controls the `Content-Disposition` header behavior:
250 ///
251 /// | Value | Header | Browser Behavior |
252 /// |-------|--------|------------------|
253 /// | `false` (default) | `inline; filename="..."` | Display in browser |
254 /// | `true` | `attachment; filename="..."` | Force download dialog |
255 ///
256 /// # Default
257 ///
258 /// `false` - PDF displays inline in the browser's PDF viewer.
259 ///
260 /// # Notes
261 ///
262 /// Browser behavior may vary. Some browsers always download PDFs
263 /// regardless of this setting, depending on user preferences.
264 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub download: Option<bool>,
266
267 /// Include background colors and images in the PDF.
268 ///
269 /// When `true`, CSS background colors, background images, and other
270 /// background graphics are included in the PDF output.
271 ///
272 /// # Default
273 ///
274 /// `true` - backgrounds are included by default.
275 ///
276 /// # Notes
277 ///
278 /// Setting this to `false` can reduce file size and is useful for
279 /// print-friendly output where backgrounds are not desired.
280 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub print_background: Option<bool>,
282
283 // ==========================================
284 // Advanced PDF Print Configuration (CDP)
285 // ==========================================
286 /// Scale of the webpage rendering. (Default: 1.0)
287 #[serde(default)]
288 pub scale: Option<f64>,
289
290 /// Paper width in inches.
291 #[serde(default)]
292 pub paper_width: Option<f64>,
293
294 /// Paper height in inches.
295 #[serde(default)]
296 pub paper_height: Option<f64>,
297
298 /// Top margin in inches.
299 #[serde(default)]
300 pub margin_top: Option<f64>,
301
302 /// Bottom margin in inches.
303 #[serde(default)]
304 pub margin_bottom: Option<f64>,
305
306 /// Left margin in inches.
307 #[serde(default)]
308 pub margin_left: Option<f64>,
309
310 /// Right margin in inches.
311 #[serde(default)]
312 pub margin_right: Option<f64>,
313
314 /// Display header and footer.
315 #[serde(default)]
316 pub display_header_footer: Option<bool>,
317
318 /// HTML template for the print header.
319 #[serde(default)]
320 pub header_template: Option<String>,
321
322 /// HTML template for the print footer.
323 #[serde(default)]
324 pub footer_template: Option<String>,
325
326 /// Paper ranges to print, e.g., '1-5, 8, 11-13'.
327 #[serde(default)]
328 pub page_ranges: Option<String>,
329
330 /// Prefer page size as defined by CSS.
331 #[serde(default)]
332 pub prefer_css_page_size: Option<bool>,
333}
334
335impl PdfFromUrlRequest {
336 /// Returns the filename, using `"document.pdf"` as the default.
337 ///
338 /// # Examples
339 ///
340 /// ```rust
341 /// use html2pdf_api::service::PdfFromUrlRequest;
342 ///
343 /// let request = PdfFromUrlRequest::default();
344 /// assert_eq!(request.filename_or_default(), "document.pdf");
345 ///
346 /// let request = PdfFromUrlRequest {
347 /// filename: Some("report.pdf".to_string()),
348 /// ..Default::default()
349 /// };
350 /// assert_eq!(request.filename_or_default(), "report.pdf");
351 /// ```
352 pub fn filename_or_default(&self) -> String {
353 self.filename
354 .clone()
355 .unwrap_or_else(|| "document.pdf".to_string())
356 }
357
358 /// Returns the JavaScript wait duration.
359 ///
360 /// Defaults to 5 seconds if not specified.
361 ///
362 /// # Examples
363 ///
364 /// ```rust
365 /// use html2pdf_api::service::PdfFromUrlRequest;
366 /// use std::time::Duration;
367 ///
368 /// let request = PdfFromUrlRequest::default();
369 /// assert_eq!(request.wait_duration(), Duration::from_secs(5));
370 ///
371 /// let request = PdfFromUrlRequest {
372 /// waitsecs: Some(10),
373 /// ..Default::default()
374 /// };
375 /// assert_eq!(request.wait_duration(), Duration::from_secs(10));
376 /// ```
377 pub fn wait_duration(&self) -> Duration {
378 Duration::from_secs(self.waitsecs.unwrap_or(5))
379 }
380
381 /// Returns whether download mode is enabled.
382 ///
383 /// When `true`, the response includes `Content-Disposition: attachment`
384 /// to force a download. When `false`, uses `inline` for in-browser display.
385 ///
386 /// # Examples
387 ///
388 /// ```rust
389 /// use html2pdf_api::service::PdfFromUrlRequest;
390 ///
391 /// let request = PdfFromUrlRequest::default();
392 /// assert!(!request.is_download()); // Default is inline
393 ///
394 /// let request = PdfFromUrlRequest {
395 /// download: Some(true),
396 /// ..Default::default()
397 /// };
398 /// assert!(request.is_download());
399 /// ```
400 pub fn is_download(&self) -> bool {
401 self.download.unwrap_or(false)
402 }
403
404 /// Returns whether landscape orientation is enabled.
405 ///
406 /// # Examples
407 ///
408 /// ```rust
409 /// use html2pdf_api::service::PdfFromUrlRequest;
410 ///
411 /// let request = PdfFromUrlRequest::default();
412 /// assert!(!request.is_landscape()); // Default is portrait
413 ///
414 /// let request = PdfFromUrlRequest {
415 /// landscape: Some(true),
416 /// ..Default::default()
417 /// };
418 /// assert!(request.is_landscape());
419 /// ```
420 pub fn is_landscape(&self) -> bool {
421 self.landscape.unwrap_or(false)
422 }
423
424 /// Returns whether background printing is enabled.
425 ///
426 /// # Examples
427 ///
428 /// ```rust
429 /// use html2pdf_api::service::PdfFromUrlRequest;
430 ///
431 /// let request = PdfFromUrlRequest::default();
432 /// assert!(request.print_background()); // Default is true
433 ///
434 /// let request = PdfFromUrlRequest {
435 /// print_background: Some(false),
436 /// ..Default::default()
437 /// };
438 /// assert!(!request.print_background());
439 /// ```
440 pub fn print_background(&self) -> bool {
441 self.print_background.unwrap_or(true)
442 }
443}
444
445/// Request parameters for converting HTML content to PDF.
446///
447/// This struct represents the request body for the HTML-to-PDF endpoint.
448/// The HTML content is loaded via a data URL, so no external server is needed.
449///
450/// # Required Fields
451///
452/// | Field | Type | Description |
453/// |-------|------|-------------|
454/// | `html` | `String` | Complete HTML document or fragment to convert |
455///
456/// # Optional Fields
457///
458/// | Field | Type | Default | Description |
459/// |-------|------|---------|-------------|
460/// | `filename` | `Option<String>` | `"document.pdf"` | Output filename |
461/// | `waitsecs` | `Option<u64>` | `2` | Seconds to wait for JavaScript |
462/// | `landscape` | `Option<bool>` | `false` | Use landscape orientation |
463/// | `download` | `Option<bool>` | `false` | Force download vs inline |
464/// | `print_background` | `Option<bool>` | `true` | Include backgrounds |
465/// | `base_url` | `Option<String>` | `None` | Base URL for relative links (not yet implemented) |
466///
467/// # HTML Content Guidelines
468///
469/// ## Complete Document (Recommended)
470///
471/// For best results, provide a complete HTML document:
472///
473/// ```html
474/// <!DOCTYPE html>
475/// <html>
476/// <head>
477/// <meta charset="UTF-8">
478/// <style>
479/// body { font-family: Arial, sans-serif; }
480/// /* Your styles here */
481/// </style>
482/// </head>
483/// <body>
484/// <h1>Your Content</h1>
485/// <p>Your content here...</p>
486/// </body>
487/// </html>
488/// ```
489///
490/// ## Fragment
491///
492/// HTML fragments are wrapped automatically, but styling may be limited:
493///
494/// ```html
495/// <h1>Hello World</h1>
496/// <p>This is a paragraph.</p>
497/// ```
498///
499/// # External Resources
500///
501/// Since HTML is loaded via data URL, external resources have limitations:
502///
503/// | Resource Type | Behavior |
504/// |---------------|----------|
505/// | Inline styles | ✅ Works |
506/// | Inline images (base64) | ✅ Works |
507/// | External CSS (`<link>`) | ⚠️ May work if absolute URL |
508/// | External images | ⚠️ May work if absolute URL |
509/// | Relative URLs | ❌ Will not resolve |
510/// | External fonts | ⚠️ May work if absolute URL |
511///
512/// For reliable results, embed all resources inline or use absolute URLs.
513///
514/// # Examples
515///
516/// ## Simple HTML conversion
517///
518/// ```rust
519/// use html2pdf_api::service::PdfFromHtmlRequest;
520///
521/// let request = PdfFromHtmlRequest {
522/// html: "<h1>Invoice #12345</h1><p>Amount: $99.99</p>".to_string(),
523/// filename: Some("invoice.pdf".to_string()),
524/// ..Default::default()
525/// };
526/// ```
527///
528/// ## Complete document with styling
529///
530/// ```rust
531/// use html2pdf_api::service::PdfFromHtmlRequest;
532///
533/// let html = r#"
534/// <!DOCTYPE html>
535/// <html>
536/// <head>
537/// <style>
538/// body { font-family: 'Helvetica', sans-serif; padding: 20px; }
539/// h1 { color: #333; border-bottom: 2px solid #007bff; }
540/// table { width: 100%; border-collapse: collapse; }
541/// th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
542/// </style>
543/// </head>
544/// <body>
545/// <h1>Monthly Report</h1>
546/// <table>
547/// <tr><th>Item</th><th>Value</th></tr>
548/// <tr><td>Revenue</td><td>$10,000</td></tr>
549/// </table>
550/// </body>
551/// </html>
552/// "#;
553///
554/// let request = PdfFromHtmlRequest {
555/// html: html.to_string(),
556/// filename: Some("report.pdf".to_string()),
557/// landscape: Some(true),
558/// ..Default::default()
559/// };
560/// ```
561///
562/// # HTTP API Usage
563///
564/// ```text
565/// POST /pdf/html
566/// Content-Type: application/json
567///
568/// {
569/// "html": "<!DOCTYPE html><html>...</html>",
570/// "filename": "document.pdf",
571/// "landscape": false,
572/// "download": true
573/// }
574/// ```
575#[derive(Debug, Clone, Serialize, Deserialize, Default)]
576pub struct PdfFromHtmlRequest {
577 /// HTML content to convert to PDF.
578 ///
579 /// Can be a complete HTML document or a fragment. Complete documents
580 /// with proper `<!DOCTYPE>`, `<html>`, `<head>`, and `<body>` tags
581 /// are recommended for consistent rendering.
582 ///
583 /// # Size Limits
584 ///
585 /// While there's no hard limit, very large HTML documents may:
586 /// - Increase processing time
587 /// - Consume more memory
588 /// - Hit URL length limits (data URLs have browser-specific limits)
589 ///
590 /// For documents over 1MB, consider hosting the HTML and using
591 /// [`PdfFromUrlRequest`] instead.
592 pub html: String,
593
594 /// Output filename for the generated PDF.
595 ///
596 /// Defaults to `"document.pdf"` if not specified.
597 /// See [`PdfFromUrlRequest::filename`] for details.
598 #[serde(default)]
599 pub filename: Option<String>,
600
601 /// Seconds to wait for JavaScript execution.
602 ///
603 /// Defaults to `2` seconds for HTML content (shorter than URL conversion
604 /// since the content is already loaded).
605 ///
606 /// Increase this value if your HTML includes JavaScript that modifies
607 /// the DOM after initial load.
608 #[serde(default)]
609 pub waitsecs: Option<u64>,
610
611 /// Use landscape page orientation.
612 ///
613 /// See [`PdfFromUrlRequest::landscape`] for details.
614 #[serde(default, skip_serializing_if = "Option::is_none")]
615 pub landscape: Option<bool>,
616
617 /// Force download instead of inline display.
618 ///
619 /// See [`PdfFromUrlRequest::download`] for details.
620 #[serde(default, skip_serializing_if = "Option::is_none")]
621 pub download: Option<bool>,
622
623 /// Include background colors and images.
624 ///
625 /// See [`PdfFromUrlRequest::print_background`] for details.
626 #[serde(default, skip_serializing_if = "Option::is_none")]
627 pub print_background: Option<bool>,
628
629 /// Base URL for resolving relative links.
630 ///
631 /// **Note:** This feature is not yet implemented. Relative URLs in
632 /// HTML content will not resolve correctly. Use absolute URLs for
633 /// external resources.
634 ///
635 /// # Future Behavior
636 ///
637 /// When implemented, this will allow relative URLs in the HTML to
638 /// resolve against the specified base:
639 ///
640 /// ```json
641 /// {
642 /// "html": "<img src=\"/images/logo.png\">",
643 /// "base_url": "https://example.com"
644 /// }
645 /// ```
646 ///
647 /// Would resolve the image to `https://example.com/images/logo.png`.
648 #[serde(default, skip_serializing_if = "Option::is_none")]
649 pub base_url: Option<String>,
650
651 // ==========================================
652 // Advanced PDF Print Configuration (CDP)
653 // ==========================================
654 /// Scale of the webpage rendering. (Default: 1.0)
655 #[serde(default)]
656 pub scale: Option<f64>,
657
658 /// Paper width in inches.
659 #[serde(default)]
660 pub paper_width: Option<f64>,
661
662 /// Paper height in inches.
663 #[serde(default)]
664 pub paper_height: Option<f64>,
665
666 /// Top margin in inches.
667 #[serde(default)]
668 pub margin_top: Option<f64>,
669
670 /// Bottom margin in inches.
671 #[serde(default)]
672 pub margin_bottom: Option<f64>,
673
674 /// Left margin in inches.
675 #[serde(default)]
676 pub margin_left: Option<f64>,
677
678 /// Right margin in inches.
679 #[serde(default)]
680 pub margin_right: Option<f64>,
681
682 /// Display header and footer.
683 #[serde(default)]
684 pub display_header_footer: Option<bool>,
685
686 /// HTML template for the print header.
687 #[serde(default)]
688 pub header_template: Option<String>,
689
690 /// HTML template for the print footer.
691 #[serde(default)]
692 pub footer_template: Option<String>,
693
694 /// Paper ranges to print, e.g., '1-5, 8, 11-13'.
695 #[serde(default)]
696 pub page_ranges: Option<String>,
697
698 /// Prefer page size as defined by CSS.
699 #[serde(default)]
700 pub prefer_css_page_size: Option<bool>,
701
702 // ==========================================
703 // Security & Isolation Configuration
704 // ==========================================
705 /// If true, forces the Headless Chrome tab into Offline Mode via CDP.
706 /// Inline JS (charting, DOM manipulation) succeeds, but all `fetch`
707 /// and network requests (SSRF vectors) immediately fail with `ERR_INTERNET_DISCONNECTED`.
708 /// HIGHLY RECOMMENDED for data:text/html workloads to prevent local data exfiltration.
709 #[serde(default)]
710 pub offline_mode: Option<bool>,
711}
712
713impl PdfFromHtmlRequest {
714 /// Returns the filename, using `"document.pdf"` as the default.
715 ///
716 /// # Examples
717 ///
718 /// ```rust
719 /// use html2pdf_api::service::PdfFromHtmlRequest;
720 ///
721 /// let request = PdfFromHtmlRequest::default();
722 /// assert_eq!(request.filename_or_default(), "document.pdf");
723 /// ```
724 pub fn filename_or_default(&self) -> String {
725 self.filename
726 .clone()
727 .unwrap_or_else(|| "document.pdf".to_string())
728 }
729
730 /// Returns the JavaScript wait duration.
731 ///
732 /// Defaults to 2 seconds for HTML content (shorter than URL conversion).
733 ///
734 /// # Examples
735 ///
736 /// ```rust
737 /// use html2pdf_api::service::PdfFromHtmlRequest;
738 /// use std::time::Duration;
739 ///
740 /// let request = PdfFromHtmlRequest::default();
741 /// assert_eq!(request.wait_duration(), Duration::from_secs(2));
742 /// ```
743 pub fn wait_duration(&self) -> Duration {
744 Duration::from_secs(self.waitsecs.unwrap_or(2))
745 }
746
747 /// Returns whether download mode is enabled.
748 ///
749 /// See [`PdfFromUrlRequest::is_download`] for details.
750 pub fn is_download(&self) -> bool {
751 self.download.unwrap_or(false)
752 }
753
754 /// Returns whether landscape orientation is enabled.
755 ///
756 /// See [`PdfFromUrlRequest::is_landscape`] for details.
757 pub fn is_landscape(&self) -> bool {
758 self.landscape.unwrap_or(false)
759 }
760
761 /// Returns whether background printing is enabled.
762 ///
763 /// See [`PdfFromUrlRequest::print_background`] for details.
764 pub fn print_background(&self) -> bool {
765 self.print_background.unwrap_or(true)
766 }
767}
768
769// ============================================================================
770// Response Types
771// ============================================================================
772
773/// Successful PDF generation result.
774///
775/// Contains the generated PDF binary data along with metadata for building
776/// the HTTP response. This type is returned by the core service functions
777/// and converted to framework-specific responses by the integrations.
778///
779/// # Fields
780///
781/// | Field | Type | Description |
782/// |-------|------|-------------|
783/// | `data` | `Vec<u8>` | Raw PDF binary data |
784/// | `filename` | `String` | Suggested filename for download |
785/// | `force_download` | `bool` | Whether to force download vs inline display |
786///
787/// # HTTP Response Headers
788///
789/// When converted to an HTTP response, this generates:
790///
791/// ```text
792/// Content-Type: application/pdf
793/// Content-Disposition: inline; filename="document.pdf" (or attachment if force_download)
794/// Cache-Control: no-cache
795/// ```
796///
797/// # Examples
798///
799/// ```rust
800/// use html2pdf_api::service::PdfResponse;
801///
802/// let response = PdfResponse::new(
803/// vec![0x25, 0x50, 0x44, 0x46], // PDF magic bytes
804/// "report.pdf".to_string(),
805/// false, // inline display
806/// );
807///
808/// assert_eq!(response.content_disposition(), "inline; filename=\"report.pdf\"");
809/// ```
810#[derive(Debug, Clone)]
811pub struct PdfResponse {
812 /// The generated PDF as raw binary data.
813 ///
814 /// This is the complete PDF file content, ready to be sent as the
815 /// HTTP response body or written to a file.
816 ///
817 /// # PDF Structure
818 ///
819 /// Valid PDF data always starts with `%PDF-` (bytes `25 50 44 46 2D`).
820 /// You can verify the data is valid by checking this header:
821 ///
822 /// ```rust
823 /// fn is_valid_pdf(data: &[u8]) -> bool {
824 /// data.starts_with(b"%PDF-")
825 /// }
826 /// ```
827 pub data: Vec<u8>,
828
829 /// Suggested filename for the PDF download.
830 ///
831 /// This is used in the `Content-Disposition` header. The actual
832 /// filename used by the browser may differ based on user settings
833 /// or browser behavior.
834 pub filename: String,
835
836 /// Whether to force download instead of inline display.
837 ///
838 /// - `true`: Uses `Content-Disposition: attachment` (forces download)
839 /// - `false`: Uses `Content-Disposition: inline` (displays in browser)
840 pub force_download: bool,
841}
842
843impl PdfResponse {
844 /// Creates a new PDF response.
845 ///
846 /// # Arguments
847 ///
848 /// * `data` - The raw PDF binary data
849 /// * `filename` - Suggested filename for the download
850 /// * `force_download` - Whether to force download vs inline display
851 ///
852 /// # Examples
853 ///
854 /// ```rust
855 /// use html2pdf_api::service::PdfResponse;
856 ///
857 /// // For inline display
858 /// let response = PdfResponse::new(
859 /// vec![0x25, 0x50, 0x44, 0x46],
860 /// "invoice.pdf".to_string(),
861 /// false,
862 /// );
863 ///
864 /// // For forced download
865 /// let response = PdfResponse::new(
866 /// vec![0x25, 0x50, 0x44, 0x46],
867 /// "confidential-report.pdf".to_string(),
868 /// true,
869 /// );
870 /// ```
871 pub fn new(data: Vec<u8>, filename: String, force_download: bool) -> Self {
872 Self {
873 data,
874 filename,
875 force_download,
876 }
877 }
878
879 /// Generates the `Content-Disposition` header value.
880 ///
881 /// Returns a properly formatted header value based on the
882 /// `force_download` setting.
883 ///
884 /// # Returns
885 ///
886 /// - `"attachment; filename=\"{filename}\""` if `force_download` is `true`
887 /// - `"inline; filename=\"{filename}\""` if `force_download` is `false`
888 ///
889 /// # Examples
890 ///
891 /// ```rust
892 /// use html2pdf_api::service::PdfResponse;
893 ///
894 /// let inline = PdfResponse::new(vec![], "doc.pdf".to_string(), false);
895 /// assert_eq!(inline.content_disposition(), "inline; filename=\"doc.pdf\"");
896 ///
897 /// let download = PdfResponse::new(vec![], "doc.pdf".to_string(), true);
898 /// assert_eq!(download.content_disposition(), "attachment; filename=\"doc.pdf\"");
899 /// ```
900 pub fn content_disposition(&self) -> String {
901 let disposition_type = if self.force_download {
902 "attachment"
903 } else {
904 "inline"
905 };
906 format!("{}; filename=\"{}\"", disposition_type, self.filename)
907 }
908
909 /// Returns the size of the PDF data in bytes.
910 ///
911 /// # Examples
912 ///
913 /// ```rust
914 /// use html2pdf_api::service::PdfResponse;
915 ///
916 /// let response = PdfResponse::new(vec![0; 1024], "doc.pdf".to_string(), false);
917 /// assert_eq!(response.size(), 1024);
918 /// ```
919 pub fn size(&self) -> usize {
920 self.data.len()
921 }
922}
923
924/// Browser pool statistics response.
925///
926/// Provides real-time metrics about the browser pool state. Useful for
927/// monitoring, debugging, and capacity planning.
928///
929/// # Fields
930///
931/// | Field | Type | Description |
932/// |-------|------|-------------|
933/// | `available` | `usize` | Browsers ready to handle requests |
934/// | `active` | `usize` | Browsers currently in use |
935/// | `total` | `usize` | Total browsers (available + active) |
936///
937/// # Understanding the Metrics
938///
939/// ```text
940/// total = available + active
941///
942/// ┌─────────────────────────────────────┐
943/// │ Browser Pool │
944/// │ ┌─────────────┬─────────────────┐ │
945/// │ │ Available │ Active │ │
946/// │ │ (idle) │ (in use) │ │
947/// │ │ [B1] [B2] │ [B3] [B4] [B5] │ │
948/// │ └─────────────┴─────────────────┘ │
949/// └─────────────────────────────────────┘
950///
951/// available = 2, active = 3, total = 5
952/// ```
953///
954/// # Health Indicators
955///
956/// | Condition | Meaning |
957/// |-----------|---------|
958/// | `available > 0` | Pool can handle new requests immediately |
959/// | `available == 0 && active < max` | New requests will create browsers |
960/// | `available == 0 && active == max` | Pool at capacity, requests may queue |
961///
962/// # HTTP API Usage
963///
964/// ```text
965/// GET /pool/stats
966///
967/// Response:
968/// {
969/// "available": 3,
970/// "active": 2,
971/// "total": 5
972/// }
973/// ```
974///
975/// # Examples
976///
977/// ```rust
978/// use html2pdf_api::service::PoolStatsResponse;
979///
980/// let stats = PoolStatsResponse {
981/// available: 3,
982/// active: 2,
983/// total: 5,
984/// };
985///
986/// // Check if pool has capacity
987/// let has_capacity = stats.available > 0;
988///
989/// // Calculate utilization
990/// let utilization = stats.active as f64 / stats.total as f64 * 100.0;
991/// println!("Pool utilization: {:.1}%", utilization); // "Pool utilization: 40.0%"
992/// ```
993#[derive(Debug, Clone, Serialize, Deserialize)]
994pub struct PoolStatsResponse {
995 /// Number of browsers available (idle) in the pool.
996 ///
997 /// These browsers are ready to handle requests immediately without
998 /// the overhead of launching a new browser process.
999 pub available: usize,
1000
1001 /// Number of browsers currently in use (checked out).
1002 ///
1003 /// These browsers are actively processing PDF generation requests.
1004 /// They will return to the available pool when the request completes.
1005 pub active: usize,
1006
1007 /// Total number of browsers in the pool.
1008 ///
1009 /// This equals `available + active`. The maximum value is determined
1010 /// by the pool's `max_pool_size` configuration.
1011 pub total: usize,
1012}
1013
1014/// Health check response.
1015///
1016/// Simple response indicating the service is running. Used by load balancers,
1017/// container orchestrators (Kubernetes), and monitoring systems.
1018///
1019/// # HTTP API Usage
1020///
1021/// ```text
1022/// GET /health
1023///
1024/// Response (200 OK):
1025/// {
1026/// "status": "healthy",
1027/// "service": "html2pdf-api"
1028/// }
1029/// ```
1030///
1031/// # Kubernetes Liveness Probe
1032///
1033/// ```yaml
1034/// livenessProbe:
1035/// httpGet:
1036/// path: /health
1037/// port: 8080
1038/// initialDelaySeconds: 10
1039/// periodSeconds: 30
1040/// ```
1041///
1042/// # Examples
1043///
1044/// ```rust
1045/// use html2pdf_api::service::HealthResponse;
1046///
1047/// let response = HealthResponse::default();
1048/// assert_eq!(response.status, "healthy");
1049/// assert_eq!(response.service, "html2pdf-api");
1050/// ```
1051#[derive(Debug, Clone, Serialize, Deserialize)]
1052pub struct HealthResponse {
1053 /// Health status, always `"healthy"` when the endpoint responds.
1054 ///
1055 /// If the service is unhealthy, the endpoint won't respond at all
1056 /// (connection refused or timeout).
1057 pub status: String,
1058
1059 /// Service name identifier.
1060 ///
1061 /// Useful when multiple services share a health check endpoint
1062 /// or for logging purposes.
1063 pub service: String,
1064}
1065
1066impl Default for HealthResponse {
1067 fn default() -> Self {
1068 Self {
1069 status: "healthy".to_string(),
1070 service: "html2pdf-api".to_string(),
1071 }
1072 }
1073}
1074
1075// ============================================================================
1076// Error Types
1077// ============================================================================
1078
1079/// Errors that can occur during PDF generation.
1080///
1081/// Each variant maps to a specific HTTP status code and error code for
1082/// consistent API responses. Use [`status_code()`](Self::status_code) and
1083/// [`error_code()`](Self::error_code) to build HTTP responses.
1084///
1085/// # HTTP Status Code Mapping
1086///
1087/// | Error Type | HTTP Status | Error Code |
1088/// |------------|-------------|------------|
1089/// | [`InvalidUrl`](Self::InvalidUrl) | 400 Bad Request | `INVALID_URL` |
1090/// | [`EmptyHtml`](Self::EmptyHtml) | 400 Bad Request | `EMPTY_HTML` |
1091/// | [`BrowserUnavailable`](Self::BrowserUnavailable) | 503 Service Unavailable | `BROWSER_UNAVAILABLE` |
1092/// | [`TabCreationFailed`](Self::TabCreationFailed) | 500 Internal Server Error | `TAB_CREATION_FAILED` |
1093/// | [`NavigationFailed`](Self::NavigationFailed) | 502 Bad Gateway | `NAVIGATION_FAILED` |
1094/// | [`NavigationTimeout`](Self::NavigationTimeout) | 504 Gateway Timeout | `NAVIGATION_TIMEOUT` |
1095/// | [`PdfGenerationFailed`](Self::PdfGenerationFailed) | 502 Bad Gateway | `PDF_GENERATION_FAILED` |
1096/// | [`Timeout`](Self::Timeout) | 504 Gateway Timeout | `TIMEOUT` |
1097/// | [`PoolShuttingDown`](Self::PoolShuttingDown) | 503 Service Unavailable | `POOL_SHUTTING_DOWN` |
1098/// | [`Internal`](Self::Internal) | 500 Internal Server Error | `INTERNAL_ERROR` |
1099///
1100/// # Error Categories
1101///
1102/// ## Client Errors (4xx)
1103///
1104/// These indicate problems with the request that the client can fix:
1105/// - [`InvalidUrl`](Self::InvalidUrl) - Malformed or missing URL
1106/// - [`EmptyHtml`](Self::EmptyHtml) - Empty HTML content
1107///
1108/// ## Server Errors (5xx)
1109///
1110/// These indicate problems on the server side:
1111/// - [`TabCreationFailed`](Self::TabCreationFailed) - Browser tab creation failed
1112/// - [`Internal`](Self::Internal) - Unexpected internal error
1113///
1114/// ## Upstream Errors (502/504)
1115///
1116/// These indicate problems with the target URL or browser:
1117/// - [`NavigationFailed`](Self::NavigationFailed) - Failed to load the URL
1118/// - [`NavigationTimeout`](Self::NavigationTimeout) - URL took too long to load
1119/// - [`PdfGenerationFailed`](Self::PdfGenerationFailed) - Browser failed to generate PDF
1120/// - [`Timeout`](Self::Timeout) - Overall operation timeout
1121///
1122/// ## Availability Errors (503)
1123///
1124/// These indicate the service is temporarily unavailable:
1125/// - [`BrowserUnavailable`](Self::BrowserUnavailable) - No browsers available in pool
1126/// - [`PoolShuttingDown`](Self::PoolShuttingDown) - Service is shutting down
1127///
1128/// # Examples
1129///
1130/// ## Error Handling
1131///
1132/// ```rust
1133/// use html2pdf_api::service::{PdfServiceError, ErrorResponse};
1134///
1135/// fn handle_result(result: Result<Vec<u8>, PdfServiceError>) -> (u16, String) {
1136/// match result {
1137/// Ok(pdf) => (200, format!("Generated {} bytes", pdf.len())),
1138/// Err(e) => {
1139/// let status = e.status_code();
1140/// let response = ErrorResponse::from(&e);
1141/// (status, serde_json::to_string(&response).unwrap())
1142/// }
1143/// }
1144/// }
1145/// ```
1146///
1147/// ## Retry Logic
1148///
1149/// ```rust
1150/// use html2pdf_api::service::PdfServiceError;
1151///
1152/// fn should_retry(error: &PdfServiceError) -> bool {
1153/// match error {
1154/// // Transient errors - worth retrying
1155/// PdfServiceError::BrowserUnavailable(_) => true,
1156/// PdfServiceError::NavigationTimeout(_) => true,
1157/// PdfServiceError::Timeout(_) => true,
1158///
1159/// // Client errors - don't retry
1160/// PdfServiceError::InvalidUrl(_) => false,
1161/// PdfServiceError::EmptyHtml => false,
1162///
1163/// // Fatal errors - don't retry
1164/// PdfServiceError::PoolShuttingDown => false,
1165///
1166/// _ => false,
1167/// }
1168/// }
1169/// ```
1170#[derive(Debug, Clone)]
1171pub enum PdfServiceError {
1172 /// The provided URL is invalid or malformed.
1173 ///
1174 /// # Causes
1175 ///
1176 /// - Empty URL string
1177 /// - Missing URL scheme (e.g., `example.com` instead of `https://example.com`)
1178 /// - Invalid URL format
1179 ///
1180 /// # Resolution
1181 ///
1182 /// Provide a valid HTTP or HTTPS URL with proper formatting.
1183 ///
1184 /// # Example Response
1185 ///
1186 /// ```json
1187 /// {
1188 /// "error": "Invalid URL: relative URL without a base",
1189 /// "code": "INVALID_URL"
1190 /// }
1191 /// ```
1192 InvalidUrl(String),
1193
1194 /// The HTML content is empty or contains only whitespace.
1195 ///
1196 /// # Causes
1197 ///
1198 /// - Empty `html` field in request
1199 /// - HTML field contains only whitespace
1200 ///
1201 /// # Resolution
1202 ///
1203 /// Provide non-empty HTML content.
1204 ///
1205 /// # Example Response
1206 ///
1207 /// ```json
1208 /// {
1209 /// "error": "HTML content is required",
1210 /// "code": "EMPTY_HTML"
1211 /// }
1212 /// ```
1213 EmptyHtml,
1214
1215 /// No browser is available in the pool.
1216 ///
1217 /// All browsers are currently in use and the pool is at maximum capacity.
1218 ///
1219 /// # Causes
1220 ///
1221 /// - High request volume exceeding pool capacity
1222 /// - Slow PDF generation causing browser exhaustion
1223 /// - Browsers failing health checks faster than replacement
1224 ///
1225 /// # Resolution
1226 ///
1227 /// - Retry after a short delay
1228 /// - Increase `max_pool_size` configuration
1229 /// - Reduce `waitsecs` to speed up PDF generation
1230 BrowserUnavailable(String),
1231
1232 /// Failed to create a new browser tab.
1233 ///
1234 /// The browser instance is available but couldn't create a new tab.
1235 ///
1236 /// # Causes
1237 ///
1238 /// - Browser process is unresponsive
1239 /// - System resource exhaustion (file descriptors, memory)
1240 /// - Browser crashed
1241 ///
1242 /// # Resolution
1243 ///
1244 /// The pool should automatically replace unhealthy browsers.
1245 /// If persistent, check system resources and browser logs.
1246 TabCreationFailed(String),
1247
1248 /// Failed to navigate to the specified URL.
1249 ///
1250 /// The browser couldn't load the target URL.
1251 ///
1252 /// # Causes
1253 ///
1254 /// - URL doesn't exist (404)
1255 /// - Server error at target URL (5xx)
1256 /// - SSL/TLS certificate issues
1257 /// - Network connectivity problems
1258 /// - Target server refusing connections
1259 ///
1260 /// # Resolution
1261 ///
1262 /// - Verify the URL is accessible
1263 /// - Check if the target server is running
1264 /// - Verify SSL certificates if using HTTPS
1265 NavigationFailed(String),
1266
1267 /// Navigation to the URL timed out.
1268 ///
1269 /// The browser started loading the URL but didn't complete within
1270 /// the allowed time.
1271 ///
1272 /// # Causes
1273 ///
1274 /// - Target server is slow to respond
1275 /// - Large page with many resources
1276 /// - Network latency issues
1277 /// - Target server overloaded
1278 ///
1279 /// # Resolution
1280 ///
1281 /// - Check target server performance
1282 /// - Increase timeout if needed (via configuration)
1283 /// - Optimize the target page
1284 NavigationTimeout(String),
1285
1286 /// Failed to generate PDF from the loaded page.
1287 ///
1288 /// The page loaded successfully but PDF generation failed.
1289 ///
1290 /// # Causes
1291 ///
1292 /// - Complex page layout that can't be rendered
1293 /// - Browser rendering issues
1294 /// - Memory exhaustion during rendering
1295 /// - Invalid page content
1296 ///
1297 /// # Resolution
1298 ///
1299 /// - Simplify the page layout
1300 /// - Check for rendering errors in browser console
1301 /// - Ensure sufficient system memory
1302 PdfGenerationFailed(String),
1303
1304 /// The overall operation timed out.
1305 ///
1306 /// The complete PDF generation operation (including queue time,
1307 /// navigation, and rendering) exceeded the maximum allowed duration.
1308 ///
1309 /// # Causes
1310 ///
1311 /// - High system load
1312 /// - Very large or complex pages
1313 /// - Slow target server
1314 /// - Insufficient `waitsecs` for JavaScript completion
1315 ///
1316 /// # Resolution
1317 ///
1318 /// - Retry the request
1319 /// - Increase timeout configuration
1320 /// - Reduce page complexity
1321 Timeout(String),
1322
1323 /// The browser pool is shutting down.
1324 ///
1325 /// The service is in the process of graceful shutdown and not
1326 /// accepting new requests.
1327 ///
1328 /// # Causes
1329 ///
1330 /// - Service restart initiated
1331 /// - Graceful shutdown in progress
1332 /// - Container/pod termination
1333 ///
1334 /// # Resolution
1335 ///
1336 /// Wait for the service to restart and retry. Do not retry
1337 /// immediately as the service is intentionally stopping.
1338 PoolShuttingDown,
1339
1340 /// An unexpected internal error occurred.
1341 ///
1342 /// Catch-all for errors that don't fit other categories.
1343 ///
1344 /// # Causes
1345 ///
1346 /// - Unexpected panic
1347 /// - Unhandled error condition
1348 /// - Bug in the application
1349 ///
1350 /// # Resolution
1351 ///
1352 /// Check server logs for details. Report persistent issues
1353 /// with reproduction steps.
1354 Internal(String),
1355}
1356
1357impl std::fmt::Display for PdfServiceError {
1358 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1359 match self {
1360 Self::InvalidUrl(msg) => write!(f, "Invalid URL: {}", msg),
1361 Self::EmptyHtml => write!(f, "HTML content is required"),
1362 Self::BrowserUnavailable(msg) => write!(f, "Browser unavailable: {}", msg),
1363 Self::TabCreationFailed(msg) => write!(f, "Failed to create tab: {}", msg),
1364 Self::NavigationFailed(msg) => write!(f, "Navigation failed: {}", msg),
1365 Self::NavigationTimeout(msg) => write!(f, "Navigation timeout: {}", msg),
1366 Self::PdfGenerationFailed(msg) => write!(f, "PDF generation failed: {}", msg),
1367 Self::Timeout(msg) => write!(f, "Operation timeout: {}", msg),
1368 Self::PoolShuttingDown => write!(f, "Pool is shutting down"),
1369 Self::Internal(msg) => write!(f, "Internal error: {}", msg),
1370 }
1371 }
1372}
1373
1374impl std::error::Error for PdfServiceError {}
1375
1376impl PdfServiceError {
1377 /// Returns the HTTP status code for this error.
1378 ///
1379 /// Maps each error type to an appropriate HTTP status code following
1380 /// REST conventions.
1381 ///
1382 /// # Status Code Categories
1383 ///
1384 /// | Range | Category | Meaning |
1385 /// |-------|----------|---------|
1386 /// | 400-499 | Client Error | Request problem (client can fix) |
1387 /// | 500-599 | Server Error | Server problem (client can't fix) |
1388 ///
1389 /// # Examples
1390 ///
1391 /// ```rust
1392 /// use html2pdf_api::service::PdfServiceError;
1393 ///
1394 /// let error = PdfServiceError::InvalidUrl("missing scheme".to_string());
1395 /// assert_eq!(error.status_code(), 400);
1396 ///
1397 /// let error = PdfServiceError::BrowserUnavailable("pool exhausted".to_string());
1398 /// assert_eq!(error.status_code(), 503);
1399 ///
1400 /// let error = PdfServiceError::NavigationTimeout("30s exceeded".to_string());
1401 /// assert_eq!(error.status_code(), 504);
1402 /// ```
1403 pub fn status_code(&self) -> u16 {
1404 match self {
1405 // Client errors (4xx)
1406 Self::InvalidUrl(_) | Self::EmptyHtml => 400,
1407
1408 // Server errors (5xx)
1409 Self::TabCreationFailed(_) | Self::Internal(_) => 500,
1410
1411 // Bad gateway (upstream errors)
1412 Self::NavigationFailed(_) | Self::PdfGenerationFailed(_) => 502,
1413
1414 // Service unavailable
1415 Self::BrowserUnavailable(_) | Self::PoolShuttingDown => 503,
1416
1417 // Gateway timeout
1418 Self::NavigationTimeout(_) | Self::Timeout(_) => 504,
1419 }
1420 }
1421
1422 /// Returns a machine-readable error code.
1423 ///
1424 /// These codes are stable and can be used for programmatic error handling
1425 /// by API clients. They are returned in the `code` field of error responses.
1426 ///
1427 /// # Error Codes
1428 ///
1429 /// | Code | Error Type |
1430 /// |------|------------|
1431 /// | `INVALID_URL` | Invalid or malformed URL |
1432 /// | `EMPTY_HTML` | Empty HTML content |
1433 /// | `BROWSER_UNAVAILABLE` | No browsers available |
1434 /// | `TAB_CREATION_FAILED` | Failed to create browser tab |
1435 /// | `NAVIGATION_FAILED` | Failed to load URL |
1436 /// | `NAVIGATION_TIMEOUT` | URL load timeout |
1437 /// | `PDF_GENERATION_FAILED` | Failed to generate PDF |
1438 /// | `TIMEOUT` | Overall operation timeout |
1439 /// | `POOL_SHUTTING_DOWN` | Service shutting down |
1440 /// | `INTERNAL_ERROR` | Unexpected internal error |
1441 ///
1442 /// # Examples
1443 ///
1444 /// ```rust
1445 /// use html2pdf_api::service::PdfServiceError;
1446 ///
1447 /// let error = PdfServiceError::InvalidUrl("test".to_string());
1448 /// assert_eq!(error.error_code(), "INVALID_URL");
1449 ///
1450 /// // Client-side handling
1451 /// match error.error_code() {
1452 /// "INVALID_URL" | "EMPTY_HTML" => println!("Fix your request"),
1453 /// "BROWSER_UNAVAILABLE" | "TIMEOUT" => println!("Retry later"),
1454 /// _ => println!("Contact support"),
1455 /// }
1456 /// ```
1457 pub fn error_code(&self) -> &'static str {
1458 match self {
1459 Self::InvalidUrl(_) => "INVALID_URL",
1460 Self::EmptyHtml => "EMPTY_HTML",
1461 Self::BrowserUnavailable(_) => "BROWSER_UNAVAILABLE",
1462 Self::TabCreationFailed(_) => "TAB_CREATION_FAILED",
1463 Self::NavigationFailed(_) => "NAVIGATION_FAILED",
1464 Self::NavigationTimeout(_) => "NAVIGATION_TIMEOUT",
1465 Self::PdfGenerationFailed(_) => "PDF_GENERATION_FAILED",
1466 Self::Timeout(_) => "TIMEOUT",
1467 Self::PoolShuttingDown => "POOL_SHUTTING_DOWN",
1468 Self::Internal(_) => "INTERNAL_ERROR",
1469 }
1470 }
1471
1472 /// Returns `true` if this error is likely transient and worth retrying.
1473 ///
1474 /// Transient errors are typically caused by temporary conditions that
1475 /// may resolve on their own. Client errors (4xx) are never transient
1476 /// as they require the client to change the request.
1477 ///
1478 /// # Retryable Errors
1479 ///
1480 /// | Error | Retryable | Reason |
1481 /// |-------|-----------|--------|
1482 /// | `BrowserUnavailable` | ✅ | Pool may free up |
1483 /// | `InvalidUrl` | ❌ | Client must fix |
1484 /// | `EmptyHtml` | ❌ | Client must fix |
1485 /// | `PoolShuttingDown` | ❌ | Intentional shutdown |
1486 ///
1487 /// # Examples
1488 ///
1489 /// ```rust,ignore
1490 /// use html2pdf_api::service::PdfServiceError;
1491 ///
1492 /// let error = PdfServiceError::BrowserUnavailable("pool full".to_string());
1493 /// if error.is_retryable() {
1494 /// // Wait and retry
1495 /// tokio::time::sleep(std::time::Duration::from_secs(1)).await;
1496 /// }
1497 ///
1498 /// let error = PdfServiceError::InvalidUrl("bad url".to_string());
1499 /// assert!(!error.is_retryable()); // Don't retry, fix the URL
1500 /// ```
1501 pub fn is_retryable(&self) -> bool {
1502 match self {
1503 // Transient - worth retrying
1504 Self::BrowserUnavailable(_)
1505 | Self::NavigationTimeout(_)
1506 | Self::Timeout(_)
1507 | Self::TabCreationFailed(_) => true,
1508
1509 // Client errors - must fix request
1510 Self::InvalidUrl(_) | Self::EmptyHtml => false,
1511
1512 // Fatal - don't retry
1513 Self::PoolShuttingDown => false,
1514
1515 // Upstream errors - maybe retry
1516 Self::NavigationFailed(_) | Self::PdfGenerationFailed(_) => true,
1517
1518 // Unknown - conservative retry
1519 Self::Internal(_) => false,
1520 }
1521 }
1522}
1523
1524/// JSON error response for API clients.
1525///
1526/// A standardized error response format returned by all PDF endpoints
1527/// when an error occurs. This structure makes it easy for API clients
1528/// to parse and handle errors programmatically.
1529///
1530/// # Fields
1531///
1532/// | Field | Type | Description |
1533/// |-------|------|-------------|
1534/// | `error` | `String` | Human-readable error message |
1535/// | `code` | `String` | Machine-readable error code |
1536///
1537/// # Response Format
1538///
1539/// ```json
1540/// {
1541/// "error": "Invalid URL: relative URL without a base",
1542/// "code": "INVALID_URL"
1543/// }
1544/// ```
1545///
1546/// # Client-Side Handling
1547///
1548/// ```typescript
1549/// // TypeScript example
1550/// interface ErrorResponse {
1551/// error: string;
1552/// code: string;
1553/// }
1554///
1555/// async function convertToPdf(url: string): Promise<Blob> {
1556/// const response = await fetch(`/pdf?url=${encodeURIComponent(url)}`);
1557///
1558/// if (!response.ok) {
1559/// const error: ErrorResponse = await response.json();
1560///
1561/// switch (error.code) {
1562/// case 'INVALID_URL':
1563/// throw new Error('Please provide a valid URL');
1564/// case 'BROWSER_UNAVAILABLE':
1565/// // Retry after delay
1566/// await sleep(1000);
1567/// return convertToPdf(url);
1568/// default:
1569/// throw new Error(error.error);
1570/// }
1571/// }
1572///
1573/// return response.blob();
1574/// }
1575/// ```
1576///
1577/// # Examples
1578///
1579/// ```rust
1580/// use html2pdf_api::service::{PdfServiceError, ErrorResponse};
1581///
1582/// let error = PdfServiceError::InvalidUrl("missing scheme".to_string());
1583/// let response = ErrorResponse::from(error);
1584///
1585/// assert_eq!(response.code, "INVALID_URL");
1586/// assert!(response.error.contains("Invalid URL"));
1587///
1588/// // Serialize to JSON
1589/// let json = serde_json::to_string(&response).unwrap();
1590/// assert!(json.contains("INVALID_URL"));
1591/// ```
1592#[derive(Debug, Clone, Serialize, Deserialize)]
1593pub struct ErrorResponse {
1594 /// Human-readable error message.
1595 ///
1596 /// This message is intended for developers and logs. It may contain
1597 /// technical details about the error cause. For user-facing messages,
1598 /// consider mapping the `code` field to localized strings.
1599 pub error: String,
1600
1601 /// Machine-readable error code.
1602 ///
1603 /// A stable, uppercase identifier for the error type. Use this for
1604 /// programmatic error handling rather than parsing the `error` message.
1605 ///
1606 /// See [`PdfServiceError::error_code()`] for the complete list of codes.
1607 pub code: String,
1608}
1609
1610impl From<&PdfServiceError> for ErrorResponse {
1611 fn from(err: &PdfServiceError) -> Self {
1612 Self {
1613 error: err.to_string(),
1614 code: err.error_code().to_string(),
1615 }
1616 }
1617}
1618
1619impl From<PdfServiceError> for ErrorResponse {
1620 fn from(err: PdfServiceError) -> Self {
1621 Self::from(&err)
1622 }
1623}
1624
1625// ============================================================================
1626// Unit Tests
1627// ============================================================================
1628
1629#[cfg(test)]
1630mod tests {
1631 use super::*;
1632
1633 #[test]
1634 fn test_pdf_from_url_request_defaults() {
1635 let request = PdfFromUrlRequest::default();
1636
1637 assert_eq!(request.filename_or_default(), "document.pdf");
1638 assert_eq!(request.wait_duration(), Duration::from_secs(5));
1639 assert!(!request.is_download());
1640 assert!(!request.is_landscape());
1641 assert!(request.print_background());
1642 }
1643
1644 #[test]
1645 fn test_pdf_from_url_request_custom_values() {
1646 let request = PdfFromUrlRequest {
1647 url: "https://example.com".to_string(),
1648 filename: Some("custom.pdf".to_string()),
1649 waitsecs: Some(10),
1650 landscape: Some(true),
1651 download: Some(true),
1652 print_background: Some(false),
1653 ..Default::default()
1654 };
1655
1656 assert_eq!(request.filename_or_default(), "custom.pdf");
1657 assert_eq!(request.wait_duration(), Duration::from_secs(10));
1658 assert!(request.is_download());
1659 assert!(request.is_landscape());
1660 assert!(!request.print_background());
1661 }
1662
1663 #[test]
1664 fn test_pdf_from_html_request_defaults() {
1665 let request = PdfFromHtmlRequest::default();
1666
1667 assert_eq!(request.filename_or_default(), "document.pdf");
1668 assert_eq!(request.wait_duration(), Duration::from_secs(2)); // Shorter default
1669 assert!(!request.is_download());
1670 assert!(!request.is_landscape());
1671 assert!(request.print_background());
1672 }
1673
1674 #[test]
1675 fn test_pdf_response_content_disposition() {
1676 let inline = PdfResponse::new(vec![], "doc.pdf".to_string(), false);
1677 assert_eq!(inline.content_disposition(), "inline; filename=\"doc.pdf\"");
1678
1679 let attachment = PdfResponse::new(vec![], "doc.pdf".to_string(), true);
1680 assert_eq!(
1681 attachment.content_disposition(),
1682 "attachment; filename=\"doc.pdf\""
1683 );
1684 }
1685
1686 #[test]
1687 fn test_pdf_response_size() {
1688 let response = PdfResponse::new(vec![0; 1024], "doc.pdf".to_string(), false);
1689 assert_eq!(response.size(), 1024);
1690 }
1691
1692 #[test]
1693 fn test_error_status_codes() {
1694 assert_eq!(
1695 PdfServiceError::InvalidUrl("".to_string()).status_code(),
1696 400
1697 );
1698 assert_eq!(PdfServiceError::EmptyHtml.status_code(), 400);
1699 assert_eq!(
1700 PdfServiceError::BrowserUnavailable("".to_string()).status_code(),
1701 503
1702 );
1703 assert_eq!(
1704 PdfServiceError::NavigationFailed("".to_string()).status_code(),
1705 502
1706 );
1707 assert_eq!(
1708 PdfServiceError::NavigationTimeout("".to_string()).status_code(),
1709 504
1710 );
1711 assert_eq!(PdfServiceError::Timeout("".to_string()).status_code(), 504);
1712 assert_eq!(PdfServiceError::PoolShuttingDown.status_code(), 503);
1713 }
1714
1715 #[test]
1716 fn test_error_codes() {
1717 assert_eq!(
1718 PdfServiceError::InvalidUrl("".to_string()).error_code(),
1719 "INVALID_URL"
1720 );
1721 assert_eq!(PdfServiceError::EmptyHtml.error_code(), "EMPTY_HTML");
1722 assert_eq!(
1723 PdfServiceError::PoolShuttingDown.error_code(),
1724 "POOL_SHUTTING_DOWN"
1725 );
1726 }
1727
1728 #[test]
1729 fn test_error_retryable() {
1730 assert!(PdfServiceError::BrowserUnavailable("".to_string()).is_retryable());
1731 assert!(PdfServiceError::Timeout("".to_string()).is_retryable());
1732 assert!(!PdfServiceError::InvalidUrl("".to_string()).is_retryable());
1733 assert!(!PdfServiceError::EmptyHtml.is_retryable());
1734 assert!(!PdfServiceError::PoolShuttingDown.is_retryable());
1735 }
1736
1737 #[test]
1738 fn test_error_response_from_error() {
1739 let error = PdfServiceError::InvalidUrl("test error".to_string());
1740 let response = ErrorResponse::from(error);
1741
1742 assert_eq!(response.code, "INVALID_URL");
1743 assert!(response.error.contains("Invalid URL"));
1744 }
1745
1746 #[test]
1747 fn test_health_response_default() {
1748 let response = HealthResponse::default();
1749 assert_eq!(response.status, "healthy");
1750 assert_eq!(response.service, "html2pdf-api");
1751 }
1752}