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.2", 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        }
331    }
332}
333
334// ============================================================================
335// Custom Response Types
336// ============================================================================
337
338/// PDF response wrapper for Rocket.
339///
340/// This responder automatically sets the correct headers for PDF responses:
341/// - `Content-Type: application/pdf`
342/// - `Content-Disposition: inline` or `attachment` based on `force_download`
343/// - `Cache-Control: no-cache`
344///
345/// # Example
346///
347/// ```rust,ignore
348/// use html2pdf_api::integrations::rocket::PdfResponder;
349///
350/// fn create_pdf_response(data: Vec<u8>) -> PdfResponder {
351///     PdfResponder {
352///         data,
353///         filename: "document.pdf".to_string(),
354///         force_download: false,
355///     }
356/// }
357/// ```
358pub struct PdfResponder {
359    /// The PDF binary data.
360    pub data: Vec<u8>,
361    /// The filename to suggest to the browser.
362    pub filename: String,
363    /// Whether to force download (attachment) or allow inline display.
364    pub force_download: bool,
365}
366
367impl<'r> Responder<'r, 'static> for PdfResponder {
368    fn respond_to(self, _request: &'r Request<'_>) -> response::Result<'static> {
369        let disposition = if self.force_download {
370            format!("attachment; filename=\"{}\"", self.filename)
371        } else {
372            format!("inline; filename=\"{}\"", self.filename)
373        };
374
375        response::Response::build()
376            .header(ContentType::PDF)
377            .header(Header::new("Cache-Control", "no-cache"))
378            .header(Header::new("Content-Disposition", disposition))
379            .sized_body(self.data.len(), std::io::Cursor::new(self.data))
380            .ok()
381    }
382}
383
384/// Error response wrapper for Rocket.
385///
386/// This responder automatically sets the correct HTTP status code based on
387/// the error type and returns a JSON error body.
388///
389/// # Example
390///
391/// ```rust,ignore
392/// use html2pdf_api::integrations::rocket::ErrorResponder;
393/// use html2pdf_api::service::ErrorResponse;
394/// use rocket::http::Status;
395///
396/// fn create_error(msg: &str) -> ErrorResponder {
397///     ErrorResponder {
398///         status: Status::BadRequest,
399///         body: ErrorResponse {
400///             error: msg.to_string(),
401///             code: "INVALID_REQUEST".to_string(),
402///         },
403///     }
404/// }
405/// ```
406pub struct ErrorResponder {
407    /// The HTTP status code.
408    pub status: Status,
409    /// The JSON error body.
410    pub body: ErrorResponse,
411}
412
413impl<'r> Responder<'r, 'static> for ErrorResponder {
414    fn respond_to(self, request: &'r Request<'_>) -> response::Result<'static> {
415        response::Response::build_from(Json(self.body).respond_to(request)?)
416            .status(self.status)
417            .ok()
418    }
419}
420
421/// Result type for Rocket handlers.
422///
423/// All pre-built handlers return this type, making it easy to use
424/// `?` operator for error handling in custom code.
425pub type HandlerResult<T> = Result<T, ErrorResponder>;
426
427// ============================================================================
428// Pre-built Handlers
429// ============================================================================
430
431/// Generate PDF from a URL.
432///
433/// This handler converts a web page to PDF using the browser pool.
434///
435/// # Endpoint
436///
437/// ```text
438/// GET /pdf?url=https://example.com&filename=output.pdf
439/// ```
440///
441/// # Query Parameters
442///
443/// | Parameter | Type | Required | Default | Description |
444/// |-----------|------|----------|---------|-------------|
445/// | `url` | string | **Yes** | - | URL to convert (must be valid HTTP/HTTPS) |
446/// | `filename` | string | No | `"document.pdf"` | Output filename |
447/// | `waitsecs` | u64 | No | `5` | Seconds to wait for JavaScript |
448/// | `landscape` | bool | No | `false` | Use landscape orientation |
449/// | `download` | bool | No | `false` | Force download vs inline display |
450/// | `print_background` | bool | No | `true` | Include background graphics |
451///
452/// # Response
453///
454/// ## Success (200 OK)
455///
456/// Returns PDF binary data with headers:
457/// - `Content-Type: application/pdf`
458/// - `Content-Disposition: inline; filename="document.pdf"` (or `attachment` if `download=true`)
459/// - `Cache-Control: no-cache`
460///
461/// ## Errors
462///
463/// | Status | Code | Description |
464/// |--------|------|-------------|
465/// | 400 | `INVALID_URL` | URL is empty or malformed |
466/// | 502 | `NAVIGATION_FAILED` | Failed to load the URL |
467/// | 503 | `BROWSER_UNAVAILABLE` | No browsers available in pool |
468/// | 504 | `TIMEOUT` | Operation timed out |
469///
470/// # Examples
471///
472/// ## Basic Request
473///
474/// ```text
475/// GET /pdf?url=https://example.com
476/// ```
477///
478/// ## With Options
479///
480/// ```text
481/// GET /pdf?url=https://example.com/report&filename=report.pdf&landscape=true&waitsecs=10
482/// ```
483///
484/// ## Force Download
485///
486/// ```text
487/// GET /pdf?url=https://example.com&download=true&filename=download.pdf
488/// ```
489///
490/// # Usage in App
491///
492/// ```rust,ignore
493/// rocket::build()
494///     .manage(pool)
495///     .mount("/", routes![pdf_from_url])
496/// ```
497#[get("/pdf?<query..>")]
498pub async fn pdf_from_url(
499    pool: &State<SharedPool>,
500    query: PdfFromUrlQuery,
501) -> HandlerResult<PdfResponder> {
502    let request: PdfFromUrlRequest = query.into();
503    let pool = Arc::clone(pool.inner());
504
505    log::debug!("PDF from URL request: {}", request.url);
506
507    // Run blocking PDF generation with timeout
508    let result = tokio::time::timeout(
509        Duration::from_secs(DEFAULT_TIMEOUT_SECS),
510        tokio::task::spawn_blocking(move || service::generate_pdf_from_url(&pool, &request)),
511    )
512    .await;
513
514    match result {
515        Ok(Ok(Ok(response))) => Ok(build_pdf_response(response)),
516        Ok(Ok(Err(e))) => Err(build_error_response(e)),
517        Ok(Err(join_err)) => {
518            log::error!("Blocking task error: {}", join_err);
519            Err(build_error_response(PdfServiceError::Internal(
520                join_err.to_string(),
521            )))
522        }
523        Err(_timeout) => {
524            log::error!(
525                "PDF generation timed out after {} seconds",
526                DEFAULT_TIMEOUT_SECS
527            );
528            Err(build_error_response(PdfServiceError::Timeout(format!(
529                "Operation timed out after {} seconds",
530                DEFAULT_TIMEOUT_SECS
531            ))))
532        }
533    }
534}
535
536/// Generate PDF from HTML content.
537///
538/// This handler converts HTML content directly to PDF without requiring
539/// a web server to host the HTML.
540///
541/// # Endpoint
542///
543/// ```text
544/// POST /pdf/html
545/// Content-Type: application/json
546/// ```
547///
548/// # Request Body
549///
550/// ```json
551/// {
552///     "html": "<html><body><h1>Hello World</h1></body></html>",
553///     "filename": "document.pdf",
554///     "waitsecs": 2,
555///     "landscape": false,
556///     "download": false,
557///     "print_background": true
558/// }
559/// ```
560///
561/// | Field | Type | Required | Default | Description |
562/// |-------|------|----------|---------|-------------|
563/// | `html` | string | **Yes** | - | HTML content to convert |
564/// | `filename` | string | No | `"document.pdf"` | Output filename |
565/// | `waitsecs` | u64 | No | `2` | Seconds to wait for JavaScript |
566/// | `landscape` | bool | No | `false` | Use landscape orientation |
567/// | `download` | bool | No | `false` | Force download vs inline display |
568/// | `print_background` | bool | No | `true` | Include background graphics |
569///
570/// # Response
571///
572/// Same as [`pdf_from_url`].
573///
574/// # Errors
575///
576/// | Status | Code | Description |
577/// |--------|------|-------------|
578/// | 400 | `EMPTY_HTML` | HTML content is empty or whitespace |
579/// | 502 | `PDF_GENERATION_FAILED` | Failed to generate PDF |
580/// | 503 | `BROWSER_UNAVAILABLE` | No browsers available |
581/// | 504 | `TIMEOUT` | Operation timed out |
582///
583/// # Example Request
584///
585/// ```bash
586/// curl -X POST http://localhost:8000/pdf/html \
587///   -H "Content-Type: application/json" \
588///   -d '{"html": "<h1>Hello</h1>", "filename": "hello.pdf"}' \
589///   --output hello.pdf
590/// ```
591///
592/// # Usage in App
593///
594/// ```rust,ignore
595/// rocket::build()
596///     .manage(pool)
597///     .mount("/", routes![pdf_from_html])
598/// ```
599#[post("/pdf/html", data = "<body>")]
600pub async fn pdf_from_html(
601    pool: &State<SharedPool>,
602    body: Json<PdfFromHtmlRequest>,
603) -> HandlerResult<PdfResponder> {
604    let request = body.into_inner();
605    let pool = Arc::clone(pool.inner());
606
607    log::debug!("PDF from HTML request: {} bytes", request.html.len());
608
609    // Run blocking PDF generation with timeout
610    let result = tokio::time::timeout(
611        Duration::from_secs(DEFAULT_TIMEOUT_SECS),
612        tokio::task::spawn_blocking(move || service::generate_pdf_from_html(&pool, &request)),
613    )
614    .await;
615
616    match result {
617        Ok(Ok(Ok(response))) => Ok(build_pdf_response(response)),
618        Ok(Ok(Err(e))) => Err(build_error_response(e)),
619        Ok(Err(join_err)) => {
620            log::error!("Blocking task error: {}", join_err);
621            Err(build_error_response(PdfServiceError::Internal(
622                join_err.to_string(),
623            )))
624        }
625        Err(_timeout) => {
626            log::error!("PDF generation timed out");
627            Err(build_error_response(PdfServiceError::Timeout(format!(
628                "Operation timed out after {} seconds",
629                DEFAULT_TIMEOUT_SECS
630            ))))
631        }
632    }
633}
634
635/// Get browser pool statistics.
636///
637/// Returns real-time metrics about the browser pool including available
638/// browsers, active browsers, and total count.
639///
640/// # Endpoint
641///
642/// ```text
643/// GET /pool/stats
644/// ```
645///
646/// # Response (200 OK)
647///
648/// ```json
649/// {
650///     "available": 3,
651///     "active": 2,
652///     "total": 5
653/// }
654/// ```
655///
656/// | Field | Type | Description |
657/// |-------|------|-------------|
658/// | `available` | number | Browsers ready to handle requests |
659/// | `active` | number | Browsers currently in use |
660/// | `total` | number | Total browsers (available + active) |
661///
662/// # Errors
663///
664/// | Status | Code | Description |
665/// |--------|------|-------------|
666/// | 500 | `POOL_LOCK_FAILED` | Failed to acquire pool lock |
667///
668/// # Use Cases
669///
670/// - Monitoring dashboards
671/// - Prometheus/Grafana metrics
672/// - Capacity planning
673/// - Debugging pool exhaustion
674///
675/// # Usage in App
676///
677/// ```rust,ignore
678/// rocket::build()
679///     .manage(pool)
680///     .mount("/", routes![pool_stats])
681/// ```
682#[get("/pool/stats")]
683pub fn pool_stats(pool: &State<SharedPool>) -> HandlerResult<Json<PoolStatsResponse>> {
684    service::get_pool_stats(pool.inner())
685        .map(Json)
686        .map_err(build_error_response)
687}
688
689/// Health check endpoint.
690///
691/// Simple endpoint that returns 200 OK if the service is running.
692/// Does not check pool health - use [`readiness_check`] for that.
693///
694/// # Endpoint
695///
696/// ```text
697/// GET /health
698/// ```
699///
700/// # Response (200 OK)
701///
702/// ```json
703/// {
704///     "status": "healthy",
705///     "service": "html2pdf-api"
706/// }
707/// ```
708///
709/// # Use Cases
710///
711/// - Kubernetes liveness probe
712/// - Load balancer health check
713/// - Uptime monitoring
714///
715/// # Kubernetes Example
716///
717/// ```yaml
718/// livenessProbe:
719///   httpGet:
720///     path: /health
721///     port: 8000
722///   initialDelaySeconds: 10
723///   periodSeconds: 30
724/// ```
725///
726/// # Usage in App
727///
728/// ```rust,ignore
729/// rocket::build()
730///     .mount("/", routes![health_check])
731/// ```
732#[get("/health")]
733pub fn health_check() -> Json<HealthResponse> {
734    Json(HealthResponse::default())
735}
736
737/// Readiness check endpoint.
738///
739/// Returns 200 OK if the pool has capacity to handle requests,
740/// 503 Service Unavailable otherwise.
741///
742/// Unlike [`health_check`], this actually checks the pool state.
743///
744/// # Endpoint
745///
746/// ```text
747/// GET /ready
748/// ```
749///
750/// # Response
751///
752/// ## Ready (200 OK)
753///
754/// ```json
755/// {
756///     "status": "ready"
757/// }
758/// ```
759///
760/// ## Not Ready (503 Service Unavailable)
761///
762/// ```json
763/// {
764///     "status": "not_ready",
765///     "reason": "no_available_capacity"
766/// }
767/// ```
768///
769/// # Readiness Criteria
770///
771/// The service is "ready" if either:
772/// - There are idle browsers available (`available > 0`), OR
773/// - There is capacity to create new browsers (`active < max_pool_size`)
774///
775/// # Use Cases
776///
777/// - Kubernetes readiness probe
778/// - Load balancer health check (remove from rotation when busy)
779/// - Auto-scaling triggers
780///
781/// # Kubernetes Example
782///
783/// ```yaml
784/// readinessProbe:
785///   httpGet:
786///     path: /ready
787///     port: 8000
788///   initialDelaySeconds: 5
789///   periodSeconds: 10
790/// ```
791///
792/// # Usage in App
793///
794/// ```rust,ignore
795/// rocket::build()
796///     .manage(pool)
797///     .mount("/", routes![readiness_check])
798/// ```
799#[get("/ready")]
800pub fn readiness_check(
801    pool: &State<SharedPool>,
802) -> Result<Json<serde_json::Value>, ErrorResponder> {
803    match service::is_pool_ready(pool.inner()) {
804        Ok(true) => Ok(Json(serde_json::json!({
805            "status": "ready"
806        }))),
807        Ok(false) => Err(ErrorResponder {
808            status: Status::ServiceUnavailable,
809            body: ErrorResponse {
810                error: "No available capacity".to_string(),
811                code: "NOT_READY".to_string(),
812            },
813        }),
814        Err(e) => Err(build_error_response(e)),
815    }
816}
817
818/// Get all routes for manual mounting.
819///
820/// Returns a vector of all pre-built routes, allowing you to mount them
821/// at a custom path prefix.
822///
823/// # Example
824///
825/// ```rust,ignore
826/// use html2pdf_api::integrations::rocket::configure_routes;
827///
828/// // Mount at root
829/// rocket::build().mount("/", configure_routes())
830///
831/// // Mount at custom prefix
832/// rocket::build().mount("/api/v1", configure_routes())
833/// ```
834///
835/// # Routes Returned
836///
837/// - `GET /pdf` - [`pdf_from_url`]
838/// - `POST /pdf/html` - [`pdf_from_html`]
839/// - `GET /pool/stats` - [`pool_stats`]
840/// - `GET /health` - [`health_check`]
841/// - `GET /ready` - [`readiness_check`]
842pub fn configure_routes() -> Vec<rocket::Route> {
843    routes![
844        pdf_from_url,
845        pdf_from_html,
846        pool_stats,
847        health_check,
848        readiness_check
849    ]
850}
851
852// ============================================================================
853// Response Builders (Internal)
854// ============================================================================
855
856/// Build PDF responder for successful PDF generation.
857fn build_pdf_response(response: PdfResponse) -> PdfResponder {
858    log::info!(
859        "PDF generated successfully: {} bytes, filename={}",
860        response.size(),
861        response.filename
862    );
863
864    PdfResponder {
865        data: response.data,
866        filename: response.filename,
867        force_download: response.force_download,
868    }
869}
870
871/// Build error responder from service error.
872fn build_error_response(error: PdfServiceError) -> ErrorResponder {
873    let status = match error.status_code() {
874        400 => Status::BadRequest,
875        502 => Status::BadGateway,
876        503 => Status::ServiceUnavailable,
877        504 => Status::GatewayTimeout,
878        _ => Status::InternalServerError,
879    };
880
881    log::warn!("PDF generation error: {} (HTTP {})", error, status.code);
882
883    ErrorResponder {
884        status,
885        body: ErrorResponse::from(error),
886    }
887}
888
889// ============================================================================
890// Extension Trait
891// ============================================================================
892
893/// Extension trait for `BrowserPool` with Rocket helpers.
894///
895/// Provides convenient methods for integrating with Rocket's managed state.
896///
897/// # Example
898///
899/// ```rust,ignore
900/// use html2pdf_api::integrations::rocket::BrowserPoolRocketExt;
901///
902/// let pool = BrowserPool::builder()
903///     .factory(Box::new(ChromeBrowserFactory::with_defaults()))
904///     .build()?;
905///
906/// pool.warmup().await?;
907///
908/// // Convert directly to Rocket managed state
909/// let shared_pool = pool.into_rocket_data();
910///
911/// rocket::build()
912///     .manage(shared_pool)
913///     .configure(configure_routes)
914/// ```
915pub trait BrowserPoolRocketExt {
916    /// Convert the pool into a shared reference suitable for Rocket's managed state.
917    ///
918    /// This is equivalent to calling `into_shared()`, returning an
919    /// `Arc<BrowserPool>` that can be passed to `rocket.manage()`.
920    ///
921    /// # Example
922    ///
923    /// ```rust,ignore
924    /// use html2pdf_api::integrations::rocket::BrowserPoolRocketExt;
925    ///
926    /// let pool = BrowserPool::builder()
927    ///     .factory(Box::new(ChromeBrowserFactory::with_defaults()))
928    ///     .build()?;
929    ///
930    /// let shared_pool = pool.into_rocket_data();
931    ///
932    /// rocket::build()
933    ///     .manage(shared_pool)
934    ///     .mount("/", routes())
935    /// ```
936    fn into_rocket_data(self) -> SharedBrowserPool;
937}
938
939impl BrowserPoolRocketExt for BrowserPool {
940    fn into_rocket_data(self) -> SharedBrowserPool {
941        self.into_shared()
942    }
943}
944
945// ============================================================================
946// Helper Functions
947// ============================================================================
948
949/// Create Rocket managed state from an existing shared pool.
950///
951/// Use this when you already have a `SharedBrowserPool` and want to
952/// use it with Rocket's `manage()`.
953///
954/// # Parameters
955///
956/// * `pool` - The shared browser pool.
957///
958/// # Returns
959///
960/// `SharedBrowserPool` ready for use with `rocket.manage()`.
961///
962/// # Example
963///
964/// ```rust,ignore
965/// use html2pdf_api::integrations::rocket::create_pool_data;
966///
967/// let shared_pool = pool.into_shared();
968/// let pool_data = create_pool_data(shared_pool);
969///
970/// rocket::build().manage(pool_data)
971/// ```
972pub fn create_pool_data(pool: SharedBrowserPool) -> SharedBrowserPool {
973    pool
974}
975
976/// Create Rocket managed state from an `Arc` reference.
977///
978/// Use this when you need to keep a reference to the pool for shutdown.
979///
980/// # Parameters
981///
982/// * `pool` - Arc reference to the shared browser pool.
983///
984/// # Returns
985///
986/// Cloned `SharedBrowserPool` ready for use with `rocket.manage()`.
987///
988/// # Example
989///
990/// ```rust,ignore
991/// use html2pdf_api::integrations::rocket::create_pool_data_from_arc;
992///
993/// let shared_pool = pool.into_shared();
994/// let pool_for_shutdown = Arc::clone(&shared_pool);
995/// let pool_data = create_pool_data_from_arc(shared_pool);
996///
997/// // Use pool_data in rocket.manage()
998/// // Use pool_for_shutdown for cleanup in shutdown fairing
999/// ```
1000pub fn create_pool_data_from_arc(pool: Arc<BrowserPool>) -> SharedBrowserPool {
1001    pool
1002}
1003
1004// ============================================================================
1005// Tests
1006// ============================================================================
1007
1008#[cfg(test)]
1009mod tests {
1010    use super::*;
1011
1012    #[test]
1013    fn test_type_alias_compiles() {
1014        // Verify the type alias is valid
1015        fn _accepts_shared_pool(_: SharedPool) {}
1016    }
1017
1018    #[test]
1019    fn test_error_responder_status_mapping() {
1020        let test_cases = vec![
1021            (
1022                PdfServiceError::InvalidUrl("".to_string()),
1023                Status::BadRequest,
1024            ),
1025            (
1026                PdfServiceError::NavigationFailed("".to_string()),
1027                Status::BadGateway,
1028            ),
1029            (
1030                PdfServiceError::BrowserUnavailable("".to_string()),
1031                Status::ServiceUnavailable,
1032            ),
1033            (
1034                PdfServiceError::Timeout("".to_string()),
1035                Status::GatewayTimeout,
1036            ),
1037            (
1038                PdfServiceError::Internal("".to_string()),
1039                Status::InternalServerError,
1040            ),
1041        ];
1042
1043        for (error, expected_status) in test_cases {
1044            let responder = build_error_response(error);
1045            assert_eq!(responder.status, expected_status);
1046        }
1047    }
1048
1049    #[test]
1050    fn test_pdf_from_url_query_conversion() {
1051        let query = PdfFromUrlQuery {
1052            url: "https://example.com".to_string(),
1053            filename: Some("test.pdf".to_string()),
1054            waitsecs: Some(10),
1055            landscape: Some(true),
1056            download: Some(false),
1057            print_background: Some(true),
1058        };
1059
1060        let request: PdfFromUrlRequest = query.into();
1061
1062        assert_eq!(request.url, "https://example.com");
1063        assert_eq!(request.filename, Some("test.pdf".to_string()));
1064        assert_eq!(request.waitsecs, Some(10));
1065        assert_eq!(request.landscape, Some(true));
1066        assert_eq!(request.download, Some(false));
1067        assert_eq!(request.print_background, Some(true));
1068    }
1069
1070    #[tokio::test]
1071    async fn test_shared_pool_type_matches() {
1072        // SharedPool and SharedBrowserPool should be compatible
1073        fn _takes_shared_pool(_: SharedPool) {}
1074        fn _returns_shared_browser_pool() -> SharedBrowserPool {
1075            Arc::new(
1076                BrowserPool::builder()
1077                    .factory(Box::new(crate::factory::mock::MockBrowserFactory::new()))
1078                    .build()
1079                    .unwrap(),
1080            )
1081        }
1082
1083        // This should compile, proving type compatibility
1084        let pool: SharedBrowserPool = _returns_shared_browser_pool();
1085        let _: SharedPool = pool;
1086    }
1087
1088    #[test]
1089    fn test_routes_returns_all_endpoints() {
1090        let all_routes = configure_routes();
1091        assert_eq!(all_routes.len(), 5);
1092    }
1093}