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