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 /// vec![0x25, 0x50, 0x44, 0x46],
748 /// "invoice.pdf".to_string(),
749 /// false,
750 /// );
751 ///
752 /// // For forced download
753 /// let response = PdfResponse::new(
754 /// vec![0x25, 0x50, 0x44, 0x46],
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/// | [`BrowserUnavailable`](Self::BrowserUnavailable) | 503 Service Unavailable | `BROWSER_UNAVAILABLE` |
980/// | [`TabCreationFailed`](Self::TabCreationFailed) | 500 Internal Server Error | `TAB_CREATION_FAILED` |
981/// | [`NavigationFailed`](Self::NavigationFailed) | 502 Bad Gateway | `NAVIGATION_FAILED` |
982/// | [`NavigationTimeout`](Self::NavigationTimeout) | 504 Gateway Timeout | `NAVIGATION_TIMEOUT` |
983/// | [`PdfGenerationFailed`](Self::PdfGenerationFailed) | 502 Bad Gateway | `PDF_GENERATION_FAILED` |
984/// | [`Timeout`](Self::Timeout) | 504 Gateway Timeout | `TIMEOUT` |
985/// | [`PoolShuttingDown`](Self::PoolShuttingDown) | 503 Service Unavailable | `POOL_SHUTTING_DOWN` |
986/// | [`Internal`](Self::Internal) | 500 Internal Server Error | `INTERNAL_ERROR` |
987///
988/// # Error Categories
989///
990/// ## Client Errors (4xx)
991///
992/// These indicate problems with the request that the client can fix:
993/// - [`InvalidUrl`](Self::InvalidUrl) - Malformed or missing URL
994/// - [`EmptyHtml`](Self::EmptyHtml) - Empty HTML content
995///
996/// ## Server Errors (5xx)
997///
998/// These indicate problems on the server side:
999/// - [`TabCreationFailed`](Self::TabCreationFailed) - Browser tab creation failed
1000/// - [`Internal`](Self::Internal) - Unexpected internal error
1001///
1002/// ## Upstream Errors (502/504)
1003///
1004/// These indicate problems with the target URL or browser:
1005/// - [`NavigationFailed`](Self::NavigationFailed) - Failed to load the URL
1006/// - [`NavigationTimeout`](Self::NavigationTimeout) - URL took too long to load
1007/// - [`PdfGenerationFailed`](Self::PdfGenerationFailed) - Browser failed to generate PDF
1008/// - [`Timeout`](Self::Timeout) - Overall operation timeout
1009///
1010/// ## Availability Errors (503)
1011///
1012/// These indicate the service is temporarily unavailable:
1013/// - [`BrowserUnavailable`](Self::BrowserUnavailable) - No browsers available in pool
1014/// - [`PoolShuttingDown`](Self::PoolShuttingDown) - Service is shutting down
1015///
1016/// # Examples
1017///
1018/// ## Error Handling
1019///
1020/// ```rust
1021/// use html2pdf_api::service::{PdfServiceError, ErrorResponse};
1022///
1023/// fn handle_result(result: Result<Vec<u8>, PdfServiceError>) -> (u16, String) {
1024/// match result {
1025/// Ok(pdf) => (200, format!("Generated {} bytes", pdf.len())),
1026/// Err(e) => {
1027/// let status = e.status_code();
1028/// let response = ErrorResponse::from(&e);
1029/// (status, serde_json::to_string(&response).unwrap())
1030/// }
1031/// }
1032/// }
1033/// ```
1034///
1035/// ## Retry Logic
1036///
1037/// ```rust
1038/// use html2pdf_api::service::PdfServiceError;
1039///
1040/// fn should_retry(error: &PdfServiceError) -> bool {
1041/// match error {
1042/// // Transient errors - worth retrying
1043/// PdfServiceError::BrowserUnavailable(_) => true,
1044/// PdfServiceError::NavigationTimeout(_) => true,
1045/// PdfServiceError::Timeout(_) => true,
1046///
1047/// // Client errors - don't retry
1048/// PdfServiceError::InvalidUrl(_) => false,
1049/// PdfServiceError::EmptyHtml => false,
1050///
1051/// // Fatal errors - don't retry
1052/// PdfServiceError::PoolShuttingDown => false,
1053///
1054/// _ => false,
1055/// }
1056/// }
1057/// ```
1058#[derive(Debug, Clone)]
1059pub enum PdfServiceError {
1060 /// The provided URL is invalid or malformed.
1061 ///
1062 /// # Causes
1063 ///
1064 /// - Empty URL string
1065 /// - Missing URL scheme (e.g., `example.com` instead of `https://example.com`)
1066 /// - Invalid URL format
1067 ///
1068 /// # Resolution
1069 ///
1070 /// Provide a valid HTTP or HTTPS URL with proper formatting.
1071 ///
1072 /// # Example Response
1073 ///
1074 /// ```json
1075 /// {
1076 /// "error": "Invalid URL: relative URL without a base",
1077 /// "code": "INVALID_URL"
1078 /// }
1079 /// ```
1080 InvalidUrl(String),
1081
1082 /// The HTML content is empty or contains only whitespace.
1083 ///
1084 /// # Causes
1085 ///
1086 /// - Empty `html` field in request
1087 /// - HTML field contains only whitespace
1088 ///
1089 /// # Resolution
1090 ///
1091 /// Provide non-empty HTML content.
1092 ///
1093 /// # Example Response
1094 ///
1095 /// ```json
1096 /// {
1097 /// "error": "HTML content is required",
1098 /// "code": "EMPTY_HTML"
1099 /// }
1100 /// ```
1101 EmptyHtml,
1102
1103 /// No browser is available in the pool.
1104 ///
1105 /// All browsers are currently in use and the pool is at maximum capacity.
1106 ///
1107 /// # Causes
1108 ///
1109 /// - High request volume exceeding pool capacity
1110 /// - Slow PDF generation causing browser exhaustion
1111 /// - Browsers failing health checks faster than replacement
1112 ///
1113 /// # Resolution
1114 ///
1115 /// - Retry after a short delay
1116 /// - Increase `max_pool_size` configuration
1117 /// - Reduce `waitsecs` to speed up PDF generation
1118 BrowserUnavailable(String),
1119
1120 /// Failed to create a new browser tab.
1121 ///
1122 /// The browser instance is available but couldn't create a new tab.
1123 ///
1124 /// # Causes
1125 ///
1126 /// - Browser process is unresponsive
1127 /// - System resource exhaustion (file descriptors, memory)
1128 /// - Browser crashed
1129 ///
1130 /// # Resolution
1131 ///
1132 /// The pool should automatically replace unhealthy browsers.
1133 /// If persistent, check system resources and browser logs.
1134 TabCreationFailed(String),
1135
1136 /// Failed to navigate to the specified URL.
1137 ///
1138 /// The browser couldn't load the target URL.
1139 ///
1140 /// # Causes
1141 ///
1142 /// - URL doesn't exist (404)
1143 /// - Server error at target URL (5xx)
1144 /// - SSL/TLS certificate issues
1145 /// - Network connectivity problems
1146 /// - Target server refusing connections
1147 ///
1148 /// # Resolution
1149 ///
1150 /// - Verify the URL is accessible
1151 /// - Check if the target server is running
1152 /// - Verify SSL certificates if using HTTPS
1153 NavigationFailed(String),
1154
1155 /// Navigation to the URL timed out.
1156 ///
1157 /// The browser started loading the URL but didn't complete within
1158 /// the allowed time.
1159 ///
1160 /// # Causes
1161 ///
1162 /// - Target server is slow to respond
1163 /// - Large page with many resources
1164 /// - Network latency issues
1165 /// - Target server overloaded
1166 ///
1167 /// # Resolution
1168 ///
1169 /// - Check target server performance
1170 /// - Increase timeout if needed (via configuration)
1171 /// - Optimize the target page
1172 NavigationTimeout(String),
1173
1174 /// Failed to generate PDF from the loaded page.
1175 ///
1176 /// The page loaded successfully but PDF generation failed.
1177 ///
1178 /// # Causes
1179 ///
1180 /// - Complex page layout that can't be rendered
1181 /// - Browser rendering issues
1182 /// - Memory exhaustion during rendering
1183 /// - Invalid page content
1184 ///
1185 /// # Resolution
1186 ///
1187 /// - Simplify the page layout
1188 /// - Check for rendering errors in browser console
1189 /// - Ensure sufficient system memory
1190 PdfGenerationFailed(String),
1191
1192 /// The overall operation timed out.
1193 ///
1194 /// The complete PDF generation operation (including queue time,
1195 /// navigation, and rendering) exceeded the maximum allowed duration.
1196 ///
1197 /// # Causes
1198 ///
1199 /// - High system load
1200 /// - Very large or complex pages
1201 /// - Slow target server
1202 /// - Insufficient `waitsecs` for JavaScript completion
1203 ///
1204 /// # Resolution
1205 ///
1206 /// - Retry the request
1207 /// - Increase timeout configuration
1208 /// - Reduce page complexity
1209 Timeout(String),
1210
1211 /// The browser pool is shutting down.
1212 ///
1213 /// The service is in the process of graceful shutdown and not
1214 /// accepting new requests.
1215 ///
1216 /// # Causes
1217 ///
1218 /// - Service restart initiated
1219 /// - Graceful shutdown in progress
1220 /// - Container/pod termination
1221 ///
1222 /// # Resolution
1223 ///
1224 /// Wait for the service to restart and retry. Do not retry
1225 /// immediately as the service is intentionally stopping.
1226 PoolShuttingDown,
1227
1228 /// An unexpected internal error occurred.
1229 ///
1230 /// Catch-all for errors that don't fit other categories.
1231 ///
1232 /// # Causes
1233 ///
1234 /// - Unexpected panic
1235 /// - Unhandled error condition
1236 /// - Bug in the application
1237 ///
1238 /// # Resolution
1239 ///
1240 /// Check server logs for details. Report persistent issues
1241 /// with reproduction steps.
1242 Internal(String),
1243}
1244
1245impl std::fmt::Display for PdfServiceError {
1246 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1247 match self {
1248 Self::InvalidUrl(msg) => write!(f, "Invalid URL: {}", msg),
1249 Self::EmptyHtml => write!(f, "HTML content is required"),
1250 Self::BrowserUnavailable(msg) => write!(f, "Browser unavailable: {}", msg),
1251 Self::TabCreationFailed(msg) => write!(f, "Failed to create tab: {}", msg),
1252 Self::NavigationFailed(msg) => write!(f, "Navigation failed: {}", msg),
1253 Self::NavigationTimeout(msg) => write!(f, "Navigation timeout: {}", msg),
1254 Self::PdfGenerationFailed(msg) => write!(f, "PDF generation failed: {}", msg),
1255 Self::Timeout(msg) => write!(f, "Operation timeout: {}", msg),
1256 Self::PoolShuttingDown => write!(f, "Pool is shutting down"),
1257 Self::Internal(msg) => write!(f, "Internal error: {}", msg),
1258 }
1259 }
1260}
1261
1262impl std::error::Error for PdfServiceError {}
1263
1264impl PdfServiceError {
1265 /// Returns the HTTP status code for this error.
1266 ///
1267 /// Maps each error type to an appropriate HTTP status code following
1268 /// REST conventions.
1269 ///
1270 /// # Status Code Categories
1271 ///
1272 /// | Range | Category | Meaning |
1273 /// |-------|----------|---------|
1274 /// | 400-499 | Client Error | Request problem (client can fix) |
1275 /// | 500-599 | Server Error | Server problem (client can't fix) |
1276 ///
1277 /// # Examples
1278 ///
1279 /// ```rust
1280 /// use html2pdf_api::service::PdfServiceError;
1281 ///
1282 /// let error = PdfServiceError::InvalidUrl("missing scheme".to_string());
1283 /// assert_eq!(error.status_code(), 400);
1284 ///
1285 /// let error = PdfServiceError::BrowserUnavailable("pool exhausted".to_string());
1286 /// assert_eq!(error.status_code(), 503);
1287 ///
1288 /// let error = PdfServiceError::NavigationTimeout("30s exceeded".to_string());
1289 /// assert_eq!(error.status_code(), 504);
1290 /// ```
1291 pub fn status_code(&self) -> u16 {
1292 match self {
1293 // Client errors (4xx)
1294 Self::InvalidUrl(_) | Self::EmptyHtml => 400,
1295
1296 // Server errors (5xx)
1297 Self::TabCreationFailed(_) | Self::Internal(_) => 500,
1298
1299 // Bad gateway (upstream errors)
1300 Self::NavigationFailed(_) | Self::PdfGenerationFailed(_) => 502,
1301
1302 // Service unavailable
1303 Self::BrowserUnavailable(_) | Self::PoolShuttingDown => 503,
1304
1305 // Gateway timeout
1306 Self::NavigationTimeout(_) | Self::Timeout(_) => 504,
1307 }
1308 }
1309
1310 /// Returns a machine-readable error code.
1311 ///
1312 /// These codes are stable and can be used for programmatic error handling
1313 /// by API clients. They are returned in the `code` field of error responses.
1314 ///
1315 /// # Error Codes
1316 ///
1317 /// | Code | Error Type |
1318 /// |------|------------|
1319 /// | `INVALID_URL` | Invalid or malformed URL |
1320 /// | `EMPTY_HTML` | Empty HTML content |
1321 /// | `BROWSER_UNAVAILABLE` | No browsers available |
1322 /// | `TAB_CREATION_FAILED` | Failed to create browser tab |
1323 /// | `NAVIGATION_FAILED` | Failed to load URL |
1324 /// | `NAVIGATION_TIMEOUT` | URL load timeout |
1325 /// | `PDF_GENERATION_FAILED` | Failed to generate PDF |
1326 /// | `TIMEOUT` | Overall operation timeout |
1327 /// | `POOL_SHUTTING_DOWN` | Service shutting down |
1328 /// | `INTERNAL_ERROR` | Unexpected internal error |
1329 ///
1330 /// # Examples
1331 ///
1332 /// ```rust
1333 /// use html2pdf_api::service::PdfServiceError;
1334 ///
1335 /// let error = PdfServiceError::InvalidUrl("test".to_string());
1336 /// assert_eq!(error.error_code(), "INVALID_URL");
1337 ///
1338 /// // Client-side handling
1339 /// match error.error_code() {
1340 /// "INVALID_URL" | "EMPTY_HTML" => println!("Fix your request"),
1341 /// "BROWSER_UNAVAILABLE" | "TIMEOUT" => println!("Retry later"),
1342 /// _ => println!("Contact support"),
1343 /// }
1344 /// ```
1345 pub fn error_code(&self) -> &'static str {
1346 match self {
1347 Self::InvalidUrl(_) => "INVALID_URL",
1348 Self::EmptyHtml => "EMPTY_HTML",
1349 Self::BrowserUnavailable(_) => "BROWSER_UNAVAILABLE",
1350 Self::TabCreationFailed(_) => "TAB_CREATION_FAILED",
1351 Self::NavigationFailed(_) => "NAVIGATION_FAILED",
1352 Self::NavigationTimeout(_) => "NAVIGATION_TIMEOUT",
1353 Self::PdfGenerationFailed(_) => "PDF_GENERATION_FAILED",
1354 Self::Timeout(_) => "TIMEOUT",
1355 Self::PoolShuttingDown => "POOL_SHUTTING_DOWN",
1356 Self::Internal(_) => "INTERNAL_ERROR",
1357 }
1358 }
1359
1360 /// Returns `true` if this error is likely transient and worth retrying.
1361 ///
1362 /// Transient errors are typically caused by temporary conditions that
1363 /// may resolve on their own. Client errors (4xx) are never transient
1364 /// as they require the client to change the request.
1365 ///
1366 /// # Retryable Errors
1367 ///
1368 /// | Error | Retryable | Reason |
1369 /// |-------|-----------|--------|
1370 /// | `BrowserUnavailable` | ✅ | Pool may free up |
1371 /// | `InvalidUrl` | ❌ | Client must fix |
1372 /// | `EmptyHtml` | ❌ | Client must fix |
1373 /// | `PoolShuttingDown` | ❌ | Intentional shutdown |
1374 ///
1375 /// # Examples
1376 ///
1377 /// ```rust,ignore
1378 /// use html2pdf_api::service::PdfServiceError;
1379 ///
1380 /// let error = PdfServiceError::BrowserUnavailable("pool full".to_string());
1381 /// if error.is_retryable() {
1382 /// // Wait and retry
1383 /// tokio::time::sleep(std::time::Duration::from_secs(1)).await;
1384 /// }
1385 ///
1386 /// let error = PdfServiceError::InvalidUrl("bad url".to_string());
1387 /// assert!(!error.is_retryable()); // Don't retry, fix the URL
1388 /// ```
1389 pub fn is_retryable(&self) -> bool {
1390 match self {
1391 // Transient - worth retrying
1392 Self::BrowserUnavailable(_)
1393 | Self::NavigationTimeout(_)
1394 | Self::Timeout(_)
1395 | Self::TabCreationFailed(_) => true,
1396
1397 // Client errors - must fix request
1398 Self::InvalidUrl(_) | Self::EmptyHtml => false,
1399
1400 // Fatal - don't retry
1401 Self::PoolShuttingDown => false,
1402
1403 // Upstream errors - maybe retry
1404 Self::NavigationFailed(_) | Self::PdfGenerationFailed(_) => true,
1405
1406 // Unknown - conservative retry
1407 Self::Internal(_) => false,
1408 }
1409 }
1410}
1411
1412/// JSON error response for API clients.
1413///
1414/// A standardized error response format returned by all PDF endpoints
1415/// when an error occurs. This structure makes it easy for API clients
1416/// to parse and handle errors programmatically.
1417///
1418/// # Fields
1419///
1420/// | Field | Type | Description |
1421/// |-------|------|-------------|
1422/// | `error` | `String` | Human-readable error message |
1423/// | `code` | `String` | Machine-readable error code |
1424///
1425/// # Response Format
1426///
1427/// ```json
1428/// {
1429/// "error": "Invalid URL: relative URL without a base",
1430/// "code": "INVALID_URL"
1431/// }
1432/// ```
1433///
1434/// # Client-Side Handling
1435///
1436/// ```typescript
1437/// // TypeScript example
1438/// interface ErrorResponse {
1439/// error: string;
1440/// code: string;
1441/// }
1442///
1443/// async function convertToPdf(url: string): Promise<Blob> {
1444/// const response = await fetch(`/pdf?url=${encodeURIComponent(url)}`);
1445///
1446/// if (!response.ok) {
1447/// const error: ErrorResponse = await response.json();
1448///
1449/// switch (error.code) {
1450/// case 'INVALID_URL':
1451/// throw new Error('Please provide a valid URL');
1452/// case 'BROWSER_UNAVAILABLE':
1453/// // Retry after delay
1454/// await sleep(1000);
1455/// return convertToPdf(url);
1456/// default:
1457/// throw new Error(error.error);
1458/// }
1459/// }
1460///
1461/// return response.blob();
1462/// }
1463/// ```
1464///
1465/// # Examples
1466///
1467/// ```rust
1468/// use html2pdf_api::service::{PdfServiceError, ErrorResponse};
1469///
1470/// let error = PdfServiceError::InvalidUrl("missing scheme".to_string());
1471/// let response = ErrorResponse::from(error);
1472///
1473/// assert_eq!(response.code, "INVALID_URL");
1474/// assert!(response.error.contains("Invalid URL"));
1475///
1476/// // Serialize to JSON
1477/// let json = serde_json::to_string(&response).unwrap();
1478/// assert!(json.contains("INVALID_URL"));
1479/// ```
1480#[derive(Debug, Clone, Serialize, Deserialize)]
1481pub struct ErrorResponse {
1482 /// Human-readable error message.
1483 ///
1484 /// This message is intended for developers and logs. It may contain
1485 /// technical details about the error cause. For user-facing messages,
1486 /// consider mapping the `code` field to localized strings.
1487 pub error: String,
1488
1489 /// Machine-readable error code.
1490 ///
1491 /// A stable, uppercase identifier for the error type. Use this for
1492 /// programmatic error handling rather than parsing the `error` message.
1493 ///
1494 /// See [`PdfServiceError::error_code()`] for the complete list of codes.
1495 pub code: String,
1496}
1497
1498impl From<&PdfServiceError> for ErrorResponse {
1499 fn from(err: &PdfServiceError) -> Self {
1500 Self {
1501 error: err.to_string(),
1502 code: err.error_code().to_string(),
1503 }
1504 }
1505}
1506
1507impl From<PdfServiceError> for ErrorResponse {
1508 fn from(err: PdfServiceError) -> Self {
1509 Self::from(&err)
1510 }
1511}
1512
1513// ============================================================================
1514// Unit Tests
1515// ============================================================================
1516
1517#[cfg(test)]
1518mod tests {
1519 use super::*;
1520
1521 #[test]
1522 fn test_pdf_from_url_request_defaults() {
1523 let request = PdfFromUrlRequest::default();
1524
1525 assert_eq!(request.filename_or_default(), "document.pdf");
1526 assert_eq!(request.wait_duration(), Duration::from_secs(5));
1527 assert!(!request.is_download());
1528 assert!(!request.is_landscape());
1529 assert!(request.print_background());
1530 }
1531
1532 #[test]
1533 fn test_pdf_from_url_request_custom_values() {
1534 let request = PdfFromUrlRequest {
1535 url: "https://example.com".to_string(),
1536 filename: Some("custom.pdf".to_string()),
1537 waitsecs: Some(10),
1538 landscape: Some(true),
1539 download: Some(true),
1540 print_background: Some(false),
1541 };
1542
1543 assert_eq!(request.filename_or_default(), "custom.pdf");
1544 assert_eq!(request.wait_duration(), Duration::from_secs(10));
1545 assert!(request.is_download());
1546 assert!(request.is_landscape());
1547 assert!(!request.print_background());
1548 }
1549
1550 #[test]
1551 fn test_pdf_from_html_request_defaults() {
1552 let request = PdfFromHtmlRequest::default();
1553
1554 assert_eq!(request.filename_or_default(), "document.pdf");
1555 assert_eq!(request.wait_duration(), Duration::from_secs(2)); // Shorter default
1556 assert!(!request.is_download());
1557 assert!(!request.is_landscape());
1558 assert!(request.print_background());
1559 }
1560
1561 #[test]
1562 fn test_pdf_response_content_disposition() {
1563 let inline = PdfResponse::new(vec![], "doc.pdf".to_string(), false);
1564 assert_eq!(inline.content_disposition(), "inline; filename=\"doc.pdf\"");
1565
1566 let attachment = PdfResponse::new(vec![], "doc.pdf".to_string(), true);
1567 assert_eq!(
1568 attachment.content_disposition(),
1569 "attachment; filename=\"doc.pdf\""
1570 );
1571 }
1572
1573 #[test]
1574 fn test_pdf_response_size() {
1575 let response = PdfResponse::new(vec![0; 1024], "doc.pdf".to_string(), false);
1576 assert_eq!(response.size(), 1024);
1577 }
1578
1579 #[test]
1580 fn test_error_status_codes() {
1581 assert_eq!(
1582 PdfServiceError::InvalidUrl("".to_string()).status_code(),
1583 400
1584 );
1585 assert_eq!(PdfServiceError::EmptyHtml.status_code(), 400);
1586 assert_eq!(
1587 PdfServiceError::BrowserUnavailable("".to_string()).status_code(),
1588 503
1589 );
1590 assert_eq!(
1591 PdfServiceError::NavigationFailed("".to_string()).status_code(),
1592 502
1593 );
1594 assert_eq!(
1595 PdfServiceError::NavigationTimeout("".to_string()).status_code(),
1596 504
1597 );
1598 assert_eq!(PdfServiceError::Timeout("".to_string()).status_code(), 504);
1599 assert_eq!(PdfServiceError::PoolShuttingDown.status_code(), 503);
1600 }
1601
1602 #[test]
1603 fn test_error_codes() {
1604 assert_eq!(
1605 PdfServiceError::InvalidUrl("".to_string()).error_code(),
1606 "INVALID_URL"
1607 );
1608 assert_eq!(PdfServiceError::EmptyHtml.error_code(), "EMPTY_HTML");
1609 assert_eq!(
1610 PdfServiceError::PoolShuttingDown.error_code(),
1611 "POOL_SHUTTING_DOWN"
1612 );
1613 }
1614
1615 #[test]
1616 fn test_error_retryable() {
1617 assert!(PdfServiceError::BrowserUnavailable("".to_string()).is_retryable());
1618 assert!(PdfServiceError::Timeout("".to_string()).is_retryable());
1619 assert!(!PdfServiceError::InvalidUrl("".to_string()).is_retryable());
1620 assert!(!PdfServiceError::EmptyHtml.is_retryable());
1621 assert!(!PdfServiceError::PoolShuttingDown.is_retryable());
1622 }
1623
1624 #[test]
1625 fn test_error_response_from_error() {
1626 let error = PdfServiceError::InvalidUrl("test error".to_string());
1627 let response = ErrorResponse::from(error);
1628
1629 assert_eq!(response.code, "INVALID_URL");
1630 assert!(response.error.contains("Invalid URL"));
1631 }
1632
1633 #[test]
1634 fn test_health_response_default() {
1635 let response = HealthResponse::default();
1636 assert_eq!(response.status, "healthy");
1637 assert_eq!(response.service, "html2pdf-api");
1638 }
1639}