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}