Skip to main content

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}