Skip to main content

html2pdf_api/integrations/
rocket.rs

1//! Rocket framework integration.
2//!
3//! This module provides helpers and pre-built handlers for using `BrowserPool`
4//! with Rocket. You can choose between using the pre-built handlers for
5//! quick setup, or writing custom handlers for full control.
6//!
7//! # Quick Start
8//!
9//! ## Option 1: Pre-built Routes (Fastest Setup)
10//!
11//! Use [`configure_routes`] to add all PDF endpoints with a single line:
12//!
13//! ```rust,ignore
14//! use rocket::launch;
15//! use html2pdf_api::prelude::*;
16//!
17//! #[launch]
18//! async fn rocket() -> _ {
19//!     let pool = init_browser_pool().await
20//!         .expect("Failed to initialize browser pool");
21//!
22//!     rocket::build()
23//!         .manage(pool)
24//!         .configure(html2pdf_api::integrations::rocket::configure_routes)
25//! }
26//! ```
27//!
28//! This gives you the following endpoints:
29//!
30//! | Method | Path | Description |
31//! |--------|------|-------------|
32//! | GET | `/pdf?url=...` | Convert URL to PDF |
33//! | POST | `/pdf/html` | Convert HTML to PDF |
34//! | GET | `/pool/stats` | Pool statistics |
35//! | GET | `/health` | Health check |
36//! | GET | `/ready` | Readiness check |
37//!
38//! ## Option 2: Mix Pre-built and Custom Handlers
39//!
40//! Use individual pre-built handlers alongside your own:
41//!
42//! ```rust,ignore
43//! use rocket::{get, launch, routes};
44//! use html2pdf_api::prelude::*;
45//! use html2pdf_api::integrations::rocket::{pdf_from_url, health_check};
46//!
47//! #[get("/custom")]
48//! fn my_custom_handler() -> &'static str {
49//!     "Custom response"
50//! }
51//!
52//! #[launch]
53//! async fn rocket() -> _ {
54//!     let pool = init_browser_pool().await
55//!         .expect("Failed to initialize browser pool");
56//!
57//!     rocket::build()
58//!         .manage(pool)
59//!         .mount("/", routes![
60//!             pdf_from_url,
61//!             health_check,
62//!             my_custom_handler
63//!         ])
64//! }
65//! ```
66//!
67//! ## Option 3: Custom Handlers with Service Functions
68//!
69//! For full control, use the service functions directly:
70//!
71//! ```rust,ignore
72//! use rocket::{get, http::Status, serde::json::Json, State};
73//! use html2pdf_api::prelude::*;
74//! use html2pdf_api::service::{generate_pdf_from_url, PdfFromUrlRequest};
75//!
76//! #[get("/my-pdf?<url>")]
77//! async fn my_pdf_handler(
78//!     pool: &State<SharedBrowserPool>,
79//!     url: String,
80//! ) -> Result<Vec<u8>, Status> {
81//!     // Custom pre-processing: auth, rate limiting, logging, etc.
82//!     log::info!("Custom handler: {}", url);
83//!
84//!     let pool = pool.inner().clone();
85//!     let request = PdfFromUrlRequest {
86//!         url,
87//!         filename: Some("custom.pdf".to_string()),
88//!         ..Default::default()
89//!     };
90//!
91//!     // Call service in blocking context
92//!     let result = tokio::task::spawn_blocking(move || {
93//!         generate_pdf_from_url(&pool, &request)
94//!     }).await;
95//!
96//!     match result {
97//!         Ok(Ok(pdf)) => {
98//!             // Custom post-processing
99//!             Ok(pdf.data)
100//!         }
101//!         Ok(Err(_)) => Err(Status::BadRequest),
102//!         Err(_) => Err(Status::InternalServerError),
103//!     }
104//! }
105//! ```
106//!
107//! ## Option 4: Full Manual Control (Original Approach)
108//!
109//! For complete control over browser operations:
110//!
111//! ```rust,ignore
112//! use rocket::{get, http::Status, State};
113//! use html2pdf_api::prelude::*;
114//!
115//! #[get("/manual-pdf")]
116//! fn manual_pdf_handler(
117//!     pool: &State<SharedBrowserPool>,
118//! ) -> Result<Vec<u8>, Status> {
119//!     let pool_guard = pool.lock()
120//!         .map_err(|_| Status::InternalServerError)?;
121//!
122//!     let browser = pool_guard.get()
123//!         .map_err(|_| Status::ServiceUnavailable)?;
124//!
125//!     let tab = browser.new_tab()
126//!         .map_err(|_| Status::InternalServerError)?;
127//!     tab.navigate_to("https://example.com")
128//!         .map_err(|_| Status::BadGateway)?;
129//!     tab.wait_until_navigated()
130//!         .map_err(|_| Status::BadGateway)?;
131//!
132//!     let pdf_data = tab.print_to_pdf(None)
133//!         .map_err(|_| Status::InternalServerError)?;
134//!
135//!     Ok(pdf_data)
136//! }
137//! ```
138//!
139//! # Setup
140//!
141//! Add to your `Cargo.toml`:
142//!
143//! ```toml
144//! [dependencies]
145//! html2pdf-api = { version = "0.3", features = ["rocket-integration"] }
146//! rocket = { version = "0.5", features = ["json"] }
147//! ```
148//!
149//! # Graceful Shutdown
150//!
151//! For proper cleanup, use Rocket's shutdown fairing:
152//!
153//! ```rust,ignore
154//! use rocket::{fairing::{Fairing, Info, Kind}, launch, Orbit, Rocket};
155//! use html2pdf_api::prelude::*;
156//! use std::sync::Arc;
157//!
158//! struct ShutdownFairing {
159//!     pool: SharedBrowserPool,
160//! }
161//!
162//! #[rocket::async_trait]
163//! impl Fairing for ShutdownFairing {
164//!     fn info(&self) -> Info {
165//!         Info {
166//!             name: "Browser Pool Shutdown",
167//!             kind: Kind::Shutdown,
168//!         }
169//!     }
170//!
171//!     async fn on_shutdown(&self, _rocket: &Rocket<Orbit>) {
172//!         if let Ok(mut pool) = self.pool.lock() {
173//!             pool.shutdown();
174//!         }
175//!     }
176//! }
177//!
178//! #[launch]
179//! async fn rocket() -> _ {
180//!     let pool = init_browser_pool().await
181//!         .expect("Failed to initialize browser pool");
182//!
183//!     let shutdown_pool = pool.clone();
184//!
185//!     rocket::build()
186//!         .manage(pool)
187//!         .attach(ShutdownFairing { pool: shutdown_pool })
188//!         .configure(html2pdf_api::integrations::rocket::configure_routes)
189//! }
190//! ```
191//!
192//! # API Reference
193//!
194//! ## Pre-built Handlers
195//!
196//! | Handler | Method | Default Path | Description |
197//! |---------|--------|--------------|-------------|
198//! | [`pdf_from_url`] | GET | `/pdf` | Convert URL to PDF |
199//! | [`pdf_from_html`] | POST | `/pdf/html` | Convert HTML to PDF |
200//! | [`pool_stats`] | GET | `/pool/stats` | Pool statistics |
201//! | [`health_check`] | GET | `/health` | Health check (always 200) |
202//! | [`readiness_check`] | GET | `/ready` | Readiness check (checks pool) |
203//!
204//! ## Type Aliases
205//!
206//! | Type | Description |
207//! |------|-------------|
208//! | [`SharedPool`] | `Arc<Mutex<BrowserPool>>` - for service functions |
209//! | [`BrowserPoolState`] | `&State<SharedBrowserPool>` - for handler parameters |
210//!
211//! ## Helper Functions
212//!
213//! | Function | Description |
214//! |----------|-------------|
215//! | [`configure_routes`] | Configure all pre-built routes |
216//! | [`configure_routes`] | Get all pre-built routes for manual mounting |
217//! | [`create_pool_data`] | Wrap `SharedBrowserPool` for Rocket managed state |
218//! | [`create_pool_data_from_arc`] | Wrap `Arc<Mutex<BrowserPool>>` for managed state |
219//!
220//! ## Extension Traits
221//!
222//! | Trait | Description |
223//! |-------|-------------|
224//! | [`BrowserPoolRocketExt`] | Adds `into_rocket_data()` to `BrowserPool` |
225
226use rocket::{
227    Request, State,
228    form::FromForm,
229    get,
230    http::{ContentType, Header, Status},
231    post,
232    response::{self, Responder},
233    routes,
234    serde::json::Json,
235};
236use std::sync::Arc;
237use std::time::Duration;
238
239use crate::SharedBrowserPool;
240use crate::pool::BrowserPool;
241use crate::service::{
242    self, DEFAULT_TIMEOUT_SECS, ErrorResponse, HealthResponse, PdfFromHtmlRequest,
243    PdfFromUrlRequest, PdfResponse, PdfServiceError, PoolStatsResponse,
244};
245
246// ============================================================================
247// Type Aliases
248// ============================================================================
249
250/// Type alias for shared browser pool.
251///
252/// This is the standard pool type used by the service functions.
253/// It's an `Arc<BrowserPool>` which allows safe sharing across
254/// threads and handlers. No outer `Mutex` needed — the pool uses
255/// fine-grained internal locks.
256///
257/// # Usage
258///
259/// ```rust,ignore
260/// use html2pdf_api::integrations::rocket::SharedPool;
261///
262/// fn my_function(pool: &SharedPool) {
263///     let browser = pool.get().unwrap();
264///     // ...
265/// }
266/// ```
267pub type SharedPool = Arc<BrowserPool>;
268
269/// Type alias for Rocket `State` wrapper around the shared pool.
270///
271/// Use this type in your handler parameters for automatic extraction:
272///
273/// ```rust,ignore
274/// use rocket::State;
275/// use html2pdf_api::integrations::rocket::BrowserPoolState;
276///
277/// #[get("/handler")]
278/// fn handler(pool: BrowserPoolState<'_>) -> &'static str {
279///     let browser = pool.get().unwrap();
280///     // ...
281///     "done"
282/// }
283/// ```
284///
285/// # Note
286///
287/// In Rocket, `State<T>` is accessed as a reference in handlers,
288/// so this type alias represents the borrowed form.
289pub type BrowserPoolState<'r> = &'r State<SharedBrowserPool>;
290
291// ============================================================================
292// Query Parameter Types (Rocket uses FromForm instead of serde::Deserialize)
293// ============================================================================
294
295/// Query parameters for PDF from URL endpoint.
296///
297/// This struct uses Rocket's `FromForm` trait for automatic deserialization
298/// from query strings, similar to Actix-web's `web::Query<T>`.
299///
300/// # Example
301///
302/// ```text
303/// GET /pdf?url=https://example.com&filename=doc.pdf&landscape=true
304/// ```
305#[derive(Debug, FromForm)]
306pub struct PdfFromUrlQuery {
307    /// URL to convert to PDF (required).
308    pub url: String,
309    /// Output filename (optional, defaults to "document.pdf").
310    pub filename: Option<String>,
311    /// Seconds to wait for JavaScript execution (optional, defaults to 5).
312    pub waitsecs: Option<u64>,
313    /// Use landscape orientation (optional, defaults to false).
314    pub landscape: Option<bool>,
315    /// Force download instead of inline display (optional, defaults to false).
316    pub download: Option<bool>,
317    /// Include background graphics (optional, defaults to true).
318    pub print_background: Option<bool>,
319}
320
321impl From<PdfFromUrlQuery> for PdfFromUrlRequest {
322    fn from(query: PdfFromUrlQuery) -> Self {
323        Self {
324            url: query.url,
325            filename: query.filename,
326            waitsecs: query.waitsecs,
327            landscape: query.landscape,
328            download: query.download,
329            print_background: query.print_background,
330            scale: None,
331            paper_width: None,
332            paper_height: None,
333            prefer_css_page_size: None,
334            margin_top: None,
335            margin_bottom: None,
336            margin_left: None,
337            margin_right: None,
338            display_header_footer: None,
339            header_template: None,
340            footer_template: None,
341            page_ranges: None,
342        }
343    }
344}
345
346// ============================================================================
347// Custom Response Types
348// ============================================================================
349
350/// PDF response wrapper for Rocket.
351///
352/// This responder automatically sets the correct headers for PDF responses:
353/// - `Content-Type: application/pdf`
354/// - `Content-Disposition: inline` or `attachment` based on `force_download`
355/// - `Cache-Control: no-cache`
356///
357/// # Example
358///
359/// ```rust,ignore
360/// use html2pdf_api::integrations::rocket::PdfResponder;
361///
362/// fn create_pdf_response(data: Vec<u8>) -> PdfResponder {
363///     PdfResponder {
364///         data,
365///         filename: "document.pdf".to_string(),
366///         force_download: false,
367///     }
368/// }
369/// ```
370pub struct PdfResponder {
371    /// The PDF binary data.
372    pub data: Vec<u8>,
373    /// The filename to suggest to the browser.
374    pub filename: String,
375    /// Whether to force download (attachment) or allow inline display.
376    pub force_download: bool,
377}
378
379impl<'r> Responder<'r, 'static> for PdfResponder {
380    fn respond_to(self, _request: &'r Request<'_>) -> response::Result<'static> {
381        let disposition = if self.force_download {
382            format!("attachment; filename=\"{}\"", self.filename)
383        } else {
384            format!("inline; filename=\"{}\"", self.filename)
385        };
386
387        response::Response::build()
388            .header(ContentType::PDF)
389            .header(Header::new("Cache-Control", "no-cache"))
390            .header(Header::new("Content-Disposition", disposition))
391            .sized_body(self.data.len(), std::io::Cursor::new(self.data))
392            .ok()
393    }
394}
395
396/// Error response wrapper for Rocket.
397///
398/// This responder automatically sets the correct HTTP status code based on
399/// the error type and returns a JSON error body.
400///
401/// # Example
402///
403/// ```rust,ignore
404/// use html2pdf_api::integrations::rocket::ErrorResponder;
405/// use html2pdf_api::service::ErrorResponse;
406/// use rocket::http::Status;
407///
408/// fn create_error(msg: &str) -> ErrorResponder {
409///     ErrorResponder {
410///         status: Status::BadRequest,
411///         body: ErrorResponse {
412///             error: msg.to_string(),
413///             code: "INVALID_REQUEST".to_string(),
414///         },
415///     }
416/// }
417/// ```
418pub struct ErrorResponder {
419    /// The HTTP status code.
420    pub status: Status,
421    /// The JSON error body.
422    pub body: ErrorResponse,
423}
424
425impl<'r> Responder<'r, 'static> for ErrorResponder {
426    fn respond_to(self, request: &'r Request<'_>) -> response::Result<'static> {
427        response::Response::build_from(Json(self.body).respond_to(request)?)
428            .status(self.status)
429            .ok()
430    }
431}
432
433/// Result type for Rocket handlers.
434///
435/// All pre-built handlers return this type, making it easy to use
436/// `?` operator for error handling in custom code.
437pub type HandlerResult<T> = Result<T, ErrorResponder>;
438
439// ============================================================================
440// Pre-built Handlers
441// ============================================================================
442
443/// Generate PDF from a URL.
444///
445/// This handler converts a web page to PDF using the browser pool.
446///
447/// # Endpoint
448///
449/// ```text
450/// GET /pdf?url=https://example.com&filename=output.pdf
451/// ```
452///
453/// # Query Parameters
454///
455/// | Parameter | Type | Required | Default | Description |
456/// |-----------|------|----------|---------|-------------|
457/// | `url` | string | **Yes** | - | URL to convert (must be valid HTTP/HTTPS) |
458/// | `filename` | string | No | `"document.pdf"` | Output filename |
459/// | `waitsecs` | u64 | No | `5` | Seconds to wait for JavaScript |
460/// | `landscape` | bool | No | `false` | Use landscape orientation |
461/// | `download` | bool | No | `false` | Force download vs inline display |
462/// | `print_background` | bool | No | `true` | Include background graphics |
463///
464/// # Response
465///
466/// ## Success (200 OK)
467///
468/// Returns PDF binary data with headers:
469/// - `Content-Type: application/pdf`
470/// - `Content-Disposition: inline; filename="document.pdf"` (or `attachment` if `download=true`)
471/// - `Cache-Control: no-cache`
472///
473/// ## Errors
474///
475/// | Status | Code | Description |
476/// |--------|------|-------------|
477/// | 400 | `INVALID_URL` | URL is empty or malformed |
478/// | 502 | `NAVIGATION_FAILED` | Failed to load the URL |
479/// | 503 | `BROWSER_UNAVAILABLE` | No browsers available in pool |
480/// | 504 | `TIMEOUT` | Operation timed out |
481///
482/// # Examples
483///
484/// ## Basic Request
485///
486/// ```text
487/// GET /pdf?url=https://example.com
488/// ```
489///
490/// ## With Options
491///
492/// ```text
493/// GET /pdf?url=https://example.com/report&filename=report.pdf&landscape=true&waitsecs=10
494/// ```
495///
496/// ## Force Download
497///
498/// ```text
499/// GET /pdf?url=https://example.com&download=true&filename=download.pdf
500/// ```
501///
502/// # Usage in App
503///
504/// ```rust,ignore
505/// rocket::build()
506///     .manage(pool)
507///     .mount("/", routes![pdf_from_url])
508/// ```
509#[get("/pdf?<query..>")]
510pub async fn pdf_from_url(
511    pool: &State<SharedPool>,
512    query: PdfFromUrlQuery,
513) -> HandlerResult<PdfResponder> {
514    let request: PdfFromUrlRequest = query.into();
515    let pool = Arc::clone(pool.inner());
516
517    log::debug!("PDF from URL request: {}", request.url);
518
519    // Run blocking PDF generation with timeout
520    let result = tokio::time::timeout(
521        Duration::from_secs(DEFAULT_TIMEOUT_SECS),
522        tokio::task::spawn_blocking(move || service::generate_pdf_from_url(&pool, &request)),
523    )
524    .await;
525
526    match result {
527        Ok(Ok(Ok(response))) => Ok(build_pdf_response(response)),
528        Ok(Ok(Err(e))) => Err(build_error_response(e)),
529        Ok(Err(join_err)) => {
530            log::error!("Blocking task error: {}", join_err);
531            Err(build_error_response(PdfServiceError::Internal(
532                join_err.to_string(),
533            )))
534        }
535        Err(_timeout) => {
536            log::error!(
537                "PDF generation timed out after {} seconds",
538                DEFAULT_TIMEOUT_SECS
539            );
540            Err(build_error_response(PdfServiceError::Timeout(format!(
541                "Operation timed out after {} seconds",
542                DEFAULT_TIMEOUT_SECS
543            ))))
544        }
545    }
546}
547
548/// Generate PDF from HTML content.
549///
550/// This handler converts HTML content directly to PDF without requiring
551/// a web server to host the HTML.
552///
553/// # Endpoint
554///
555/// ```text
556/// POST /pdf/html
557/// Content-Type: application/json
558/// ```
559///
560/// # Request Body
561///
562/// ```json
563/// {
564///     "html": "<html><body><h1>Hello World</h1></body></html>",
565///     "filename": "document.pdf",
566///     "waitsecs": 2,
567///     "landscape": false,
568///     "download": false,
569///     "print_background": true
570/// }
571/// ```
572///
573/// | Field | Type | Required | Default | Description |
574/// |-------|------|----------|---------|-------------|
575/// | `html` | string | **Yes** | - | HTML content to convert |
576/// | `filename` | string | No | `"document.pdf"` | Output filename |
577/// | `waitsecs` | u64 | No | `2` | Seconds to wait for JavaScript |
578/// | `landscape` | bool | No | `false` | Use landscape orientation |
579/// | `download` | bool | No | `false` | Force download vs inline display |
580/// | `print_background` | bool | No | `true` | Include background graphics |
581///
582/// # Response
583///
584/// Same as [`pdf_from_url`].
585///
586/// # Errors
587///
588/// | Status | Code | Description |
589/// |--------|------|-------------|
590/// | 400 | `EMPTY_HTML` | HTML content is empty or whitespace |
591/// | 502 | `PDF_GENERATION_FAILED` | Failed to generate PDF |
592/// | 503 | `BROWSER_UNAVAILABLE` | No browsers available |
593/// | 504 | `TIMEOUT` | Operation timed out |
594///
595/// # Example Request
596///
597/// ```bash
598/// curl -X POST http://localhost:8000/pdf/html \
599///   -H "Content-Type: application/json" \
600///   -d '{"html": "<h1>Hello</h1>", "filename": "hello.pdf"}' \
601///   --output hello.pdf
602/// ```
603///
604/// # Usage in App
605///
606/// ```rust,ignore
607/// rocket::build()
608///     .manage(pool)
609///     .mount("/", routes![pdf_from_html])
610/// ```
611#[post("/pdf/html", data = "<body>")]
612pub async fn pdf_from_html(
613    pool: &State<SharedPool>,
614    body: Json<PdfFromHtmlRequest>,
615) -> HandlerResult<PdfResponder> {
616    let request = body.into_inner();
617    let pool = Arc::clone(pool.inner());
618
619    log::debug!("PDF from HTML request: {} bytes", request.html.len());
620
621    // Run blocking PDF generation with timeout
622    let result = tokio::time::timeout(
623        Duration::from_secs(DEFAULT_TIMEOUT_SECS),
624        tokio::task::spawn_blocking(move || service::generate_pdf_from_html(&pool, &request)),
625    )
626    .await;
627
628    match result {
629        Ok(Ok(Ok(response))) => Ok(build_pdf_response(response)),
630        Ok(Ok(Err(e))) => Err(build_error_response(e)),
631        Ok(Err(join_err)) => {
632            log::error!("Blocking task error: {}", join_err);
633            Err(build_error_response(PdfServiceError::Internal(
634                join_err.to_string(),
635            )))
636        }
637        Err(_timeout) => {
638            log::error!("PDF generation timed out");
639            Err(build_error_response(PdfServiceError::Timeout(format!(
640                "Operation timed out after {} seconds",
641                DEFAULT_TIMEOUT_SECS
642            ))))
643        }
644    }
645}
646
647/// Get browser pool statistics.
648///
649/// Returns real-time metrics about the browser pool including available
650/// browsers, active browsers, and total count.
651///
652/// # Endpoint
653///
654/// ```text
655/// GET /pool/stats
656/// ```
657///
658/// # Response (200 OK)
659///
660/// ```json
661/// {
662///     "available": 3,
663///     "active": 2,
664///     "total": 5
665/// }
666/// ```
667///
668/// | Field | Type | Description |
669/// |-------|------|-------------|
670/// | `available` | number | Browsers ready to handle requests |
671/// | `active` | number | Browsers currently in use |
672/// | `total` | number | Total browsers (available + active) |
673///
674/// # Errors
675///
676/// | Status | Code | Description |
677/// |--------|------|-------------|
678/// | 500 | `POOL_LOCK_FAILED` | Failed to acquire pool lock |
679///
680/// # Use Cases
681///
682/// - Monitoring dashboards
683/// - Prometheus/Grafana metrics
684/// - Capacity planning
685/// - Debugging pool exhaustion
686///
687/// # Usage in App
688///
689/// ```rust,ignore
690/// rocket::build()
691///     .manage(pool)
692///     .mount("/", routes![pool_stats])
693/// ```
694#[get("/pool/stats")]
695pub fn pool_stats(pool: &State<SharedPool>) -> HandlerResult<Json<PoolStatsResponse>> {
696    service::get_pool_stats(pool.inner())
697        .map(Json)
698        .map_err(build_error_response)
699}
700
701/// Health check endpoint.
702///
703/// Simple endpoint that returns 200 OK if the service is running.
704/// Does not check pool health - use [`readiness_check`] for that.
705///
706/// # Endpoint
707///
708/// ```text
709/// GET /health
710/// ```
711///
712/// # Response (200 OK)
713///
714/// ```json
715/// {
716///     "status": "healthy",
717///     "service": "html2pdf-api"
718/// }
719/// ```
720///
721/// # Use Cases
722///
723/// - Kubernetes liveness probe
724/// - Load balancer health check
725/// - Uptime monitoring
726///
727/// # Kubernetes Example
728///
729/// ```yaml
730/// livenessProbe:
731///   httpGet:
732///     path: /health
733///     port: 8000
734///   initialDelaySeconds: 10
735///   periodSeconds: 30
736/// ```
737///
738/// # Usage in App
739///
740/// ```rust,ignore
741/// rocket::build()
742///     .mount("/", routes![health_check])
743/// ```
744#[get("/health")]
745pub fn health_check() -> Json<HealthResponse> {
746    Json(HealthResponse::default())
747}
748
749/// Readiness check endpoint.
750///
751/// Returns 200 OK if the pool has capacity to handle requests,
752/// 503 Service Unavailable otherwise.
753///
754/// Unlike [`health_check`], this actually checks the pool state.
755///
756/// # Endpoint
757///
758/// ```text
759/// GET /ready
760/// ```
761///
762/// # Response
763///
764/// ## Ready (200 OK)
765///
766/// ```json
767/// {
768///     "status": "ready"
769/// }
770/// ```
771///
772/// ## Not Ready (503 Service Unavailable)
773///
774/// ```json
775/// {
776///     "status": "not_ready",
777///     "reason": "no_available_capacity"
778/// }
779/// ```
780///
781/// # Readiness Criteria
782///
783/// The service is "ready" if either:
784/// - There are idle browsers available (`available > 0`), OR
785/// - There is capacity to create new browsers (`active < max_pool_size`)
786///
787/// # Use Cases
788///
789/// - Kubernetes readiness probe
790/// - Load balancer health check (remove from rotation when busy)
791/// - Auto-scaling triggers
792///
793/// # Kubernetes Example
794///
795/// ```yaml
796/// readinessProbe:
797///   httpGet:
798///     path: /ready
799///     port: 8000
800///   initialDelaySeconds: 5
801///   periodSeconds: 10
802/// ```
803///
804/// # Usage in App
805///
806/// ```rust,ignore
807/// rocket::build()
808///     .manage(pool)
809///     .mount("/", routes![readiness_check])
810/// ```
811#[get("/ready")]
812pub fn readiness_check(
813    pool: &State<SharedPool>,
814) -> Result<Json<serde_json::Value>, ErrorResponder> {
815    match service::is_pool_ready(pool.inner()) {
816        Ok(true) => Ok(Json(serde_json::json!({
817            "status": "ready"
818        }))),
819        Ok(false) => Err(ErrorResponder {
820            status: Status::ServiceUnavailable,
821            body: ErrorResponse {
822                error: "No available capacity".to_string(),
823                code: "NOT_READY".to_string(),
824            },
825        }),
826        Err(e) => Err(build_error_response(e)),
827    }
828}
829
830/// Get all routes for manual mounting.
831///
832/// Returns a vector of all pre-built routes, allowing you to mount them
833/// at a custom path prefix.
834///
835/// # Example
836///
837/// ```rust,ignore
838/// use html2pdf_api::integrations::rocket::configure_routes;
839///
840/// // Mount at root
841/// rocket::build().mount("/", configure_routes())
842///
843/// // Mount at custom prefix
844/// rocket::build().mount("/api/v1", configure_routes())
845/// ```
846///
847/// # Routes Returned
848///
849/// - `GET /pdf` - [`pdf_from_url`]
850/// - `POST /pdf/html` - [`pdf_from_html`]
851/// - `GET /pool/stats` - [`pool_stats`]
852/// - `GET /health` - [`health_check`]
853/// - `GET /ready` - [`readiness_check`]
854pub fn configure_routes() -> Vec<rocket::Route> {
855    routes![
856        pdf_from_url,
857        pdf_from_html,
858        pool_stats,
859        health_check,
860        readiness_check
861    ]
862}
863
864// ============================================================================
865// Response Builders (Internal)
866// ============================================================================
867
868/// Build PDF responder for successful PDF generation.
869fn build_pdf_response(response: PdfResponse) -> PdfResponder {
870    log::info!(
871        "PDF generated successfully: {} bytes, filename={}",
872        response.size(),
873        response.filename
874    );
875
876    PdfResponder {
877        data: response.data,
878        filename: response.filename,
879        force_download: response.force_download,
880    }
881}
882
883/// Build error responder from service error.
884fn build_error_response(error: PdfServiceError) -> ErrorResponder {
885    let status = match error.status_code() {
886        400 => Status::BadRequest,
887        502 => Status::BadGateway,
888        503 => Status::ServiceUnavailable,
889        504 => Status::GatewayTimeout,
890        _ => Status::InternalServerError,
891    };
892
893    log::warn!("PDF generation error: {} (HTTP {})", error, status.code);
894
895    ErrorResponder {
896        status,
897        body: ErrorResponse::from(error),
898    }
899}
900
901// ============================================================================
902// Extension Trait
903// ============================================================================
904
905/// Extension trait for `BrowserPool` with Rocket helpers.
906///
907/// Provides convenient methods for integrating with Rocket's managed state.
908///
909/// # Example
910///
911/// ```rust,ignore
912/// use html2pdf_api::integrations::rocket::BrowserPoolRocketExt;
913///
914/// let pool = BrowserPool::builder()
915///     .factory(Box::new(ChromeBrowserFactory::with_defaults()))
916///     .build()?;
917///
918/// pool.warmup().await?;
919///
920/// // Convert directly to Rocket managed state
921/// let shared_pool = pool.into_rocket_data();
922///
923/// rocket::build()
924///     .manage(shared_pool)
925///     .configure(configure_routes)
926/// ```
927pub trait BrowserPoolRocketExt {
928    /// Convert the pool into a shared reference suitable for Rocket's managed state.
929    ///
930    /// This is equivalent to calling `into_shared()`, returning an
931    /// `Arc<BrowserPool>` that can be passed to `rocket.manage()`.
932    ///
933    /// # Example
934    ///
935    /// ```rust,ignore
936    /// use html2pdf_api::integrations::rocket::BrowserPoolRocketExt;
937    ///
938    /// let pool = BrowserPool::builder()
939    ///     .factory(Box::new(ChromeBrowserFactory::with_defaults()))
940    ///     .build()?;
941    ///
942    /// let shared_pool = pool.into_rocket_data();
943    ///
944    /// rocket::build()
945    ///     .manage(shared_pool)
946    ///     .mount("/", routes())
947    /// ```
948    fn into_rocket_data(self) -> SharedBrowserPool;
949}
950
951impl BrowserPoolRocketExt for BrowserPool {
952    fn into_rocket_data(self) -> SharedBrowserPool {
953        self.into_shared()
954    }
955}
956
957// ============================================================================
958// Helper Functions
959// ============================================================================
960
961/// Create Rocket managed state from an existing shared pool.
962///
963/// Use this when you already have a `SharedBrowserPool` and want to
964/// use it with Rocket's `manage()`.
965///
966/// # Parameters
967///
968/// * `pool` - The shared browser pool.
969///
970/// # Returns
971///
972/// `SharedBrowserPool` ready for use with `rocket.manage()`.
973///
974/// # Example
975///
976/// ```rust,ignore
977/// use html2pdf_api::integrations::rocket::create_pool_data;
978///
979/// let shared_pool = pool.into_shared();
980/// let pool_data = create_pool_data(shared_pool);
981///
982/// rocket::build().manage(pool_data)
983/// ```
984pub fn create_pool_data(pool: SharedBrowserPool) -> SharedBrowserPool {
985    pool
986}
987
988/// Create Rocket managed state from an `Arc` reference.
989///
990/// Use this when you need to keep a reference to the pool for shutdown.
991///
992/// # Parameters
993///
994/// * `pool` - Arc reference to the shared browser pool.
995///
996/// # Returns
997///
998/// Cloned `SharedBrowserPool` ready for use with `rocket.manage()`.
999///
1000/// # Example
1001///
1002/// ```rust,ignore
1003/// use html2pdf_api::integrations::rocket::create_pool_data_from_arc;
1004///
1005/// let shared_pool = pool.into_shared();
1006/// let pool_for_shutdown = Arc::clone(&shared_pool);
1007/// let pool_data = create_pool_data_from_arc(shared_pool);
1008///
1009/// // Use pool_data in rocket.manage()
1010/// // Use pool_for_shutdown for cleanup in shutdown fairing
1011/// ```
1012pub fn create_pool_data_from_arc(pool: Arc<BrowserPool>) -> SharedBrowserPool {
1013    pool
1014}
1015
1016// ============================================================================
1017// Tests
1018// ============================================================================
1019
1020#[cfg(test)]
1021mod tests {
1022    use super::*;
1023
1024    #[test]
1025    fn test_type_alias_compiles() {
1026        // Verify the type alias is valid
1027        fn _accepts_shared_pool(_: SharedPool) {}
1028    }
1029
1030    #[test]
1031    fn test_error_responder_status_mapping() {
1032        let test_cases = vec![
1033            (
1034                PdfServiceError::InvalidUrl("".to_string()),
1035                Status::BadRequest,
1036            ),
1037            (
1038                PdfServiceError::NavigationFailed("".to_string()),
1039                Status::BadGateway,
1040            ),
1041            (
1042                PdfServiceError::BrowserUnavailable("".to_string()),
1043                Status::ServiceUnavailable,
1044            ),
1045            (
1046                PdfServiceError::Timeout("".to_string()),
1047                Status::GatewayTimeout,
1048            ),
1049            (
1050                PdfServiceError::Internal("".to_string()),
1051                Status::InternalServerError,
1052            ),
1053        ];
1054
1055        for (error, expected_status) in test_cases {
1056            let responder = build_error_response(error);
1057            assert_eq!(responder.status, expected_status);
1058        }
1059    }
1060
1061    #[test]
1062    fn test_pdf_from_url_query_conversion() {
1063        let query = PdfFromUrlQuery {
1064            url: "https://example.com".to_string(),
1065            filename: Some("test.pdf".to_string()),
1066            waitsecs: Some(10),
1067            landscape: Some(true),
1068            download: Some(false),
1069            print_background: Some(true),
1070        };
1071
1072        let request: PdfFromUrlRequest = query.into();
1073
1074        assert_eq!(request.url, "https://example.com");
1075        assert_eq!(request.filename, Some("test.pdf".to_string()));
1076        assert_eq!(request.waitsecs, Some(10));
1077        assert_eq!(request.landscape, Some(true));
1078        assert_eq!(request.download, Some(false));
1079        assert_eq!(request.print_background, Some(true));
1080    }
1081
1082    #[tokio::test]
1083    async fn test_shared_pool_type_matches() {
1084        // SharedPool and SharedBrowserPool should be compatible
1085        fn _takes_shared_pool(_: SharedPool) {}
1086        fn _returns_shared_browser_pool() -> SharedBrowserPool {
1087            Arc::new(
1088                BrowserPool::builder()
1089                    .factory(Box::new(crate::factory::mock::MockBrowserFactory::new()))
1090                    .build()
1091                    .unwrap(),
1092            )
1093        }
1094
1095        // This should compile, proving type compatibility
1096        let pool: SharedBrowserPool = _returns_shared_browser_pool();
1097        let _: SharedPool = pool;
1098    }
1099
1100    #[test]
1101    fn test_routes_returns_all_endpoints() {
1102        let all_routes = configure_routes();
1103        assert_eq!(all_routes.len(), 5);
1104    }
1105}