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