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//! | [`routes()`] | Get all 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 Build, Request, Rocket, 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// ============================================================================
820// Route Configuration
821// ============================================================================
822
823/// Configure all PDF routes.
824///
825/// Adds all pre-built handlers to the Rocket instance with default paths.
826/// This is the easiest way to set up the PDF service.
827///
828/// # Routes Added
829///
830/// | Method | Path | Handler | Description |
831/// |--------|------|---------|-------------|
832/// | GET | `/pdf` | [`pdf_from_url`] | Convert URL to PDF |
833/// | POST | `/pdf/html` | [`pdf_from_html`] | Convert HTML to PDF |
834/// | GET | `/pool/stats` | [`pool_stats`] | Pool statistics |
835/// | GET | `/health` | [`health_check`] | Health check |
836/// | GET | `/ready` | [`readiness_check`] | Readiness check |
837///
838/// # Example
839///
840/// ```rust,ignore
841/// use rocket::launch;
842/// use html2pdf_api::prelude::*;
843/// use html2pdf_api::integrations::rocket::configure_routes;
844///
845/// #[launch]
846/// async fn rocket() -> _ {
847/// let pool = init_browser_pool().await
848/// .expect("Failed to initialize browser pool");
849///
850/// rocket::build()
851/// .manage(pool)
852/// .configure(configure_routes)
853/// }
854/// ```
855///
856/// # Adding Custom Routes
857///
858/// You can combine `configure_routes` with additional routes:
859///
860/// ```rust,ignore
861/// use rocket::{get, routes};
862///
863/// #[get("/custom")]
864/// fn my_custom_handler() -> &'static str { "custom" }
865///
866/// rocket::build()
867/// .manage(pool)
868/// .configure(configure_routes) // Pre-built routes
869/// .mount("/", routes![my_custom_handler]) // Your routes
870/// ```
871///
872/// # Custom Path Prefix
873///
874/// To mount routes under a prefix, use [`routes()`] directly:
875///
876/// ```rust,ignore
877/// use html2pdf_api::integrations::rocket::routes;
878///
879/// rocket::build()
880/// .manage(pool)
881/// .mount("/api/v1", routes())
882/// // Routes will be: /api/v1/pdf, /api/v1/health, etc.
883/// ```
884pub fn configure_routes(rocket: Rocket<Build>) -> Rocket<Build> {
885 rocket.mount("/", routes())
886}
887
888/// Get all routes for manual mounting.
889///
890/// Returns a vector of all pre-built routes, allowing you to mount them
891/// at a custom path prefix.
892///
893/// # Example
894///
895/// ```rust,ignore
896/// use html2pdf_api::integrations::rocket::routes;
897///
898/// // Mount at root
899/// rocket::build().mount("/", routes())
900///
901/// // Mount at custom prefix
902/// rocket::build().mount("/api/v1", routes())
903/// ```
904///
905/// # Routes Returned
906///
907/// - `GET /pdf` - [`pdf_from_url`]
908/// - `POST /pdf/html` - [`pdf_from_html`]
909/// - `GET /pool/stats` - [`pool_stats`]
910/// - `GET /health` - [`health_check`]
911/// - `GET /ready` - [`readiness_check`]
912pub fn routes() -> Vec<rocket::Route> {
913 routes![
914 pdf_from_url,
915 pdf_from_html,
916 pool_stats,
917 health_check,
918 readiness_check
919 ]
920}
921
922// ============================================================================
923// Response Builders (Internal)
924// ============================================================================
925
926/// Build PDF responder for successful PDF generation.
927fn build_pdf_response(response: PdfResponse) -> PdfResponder {
928 log::info!(
929 "PDF generated successfully: {} bytes, filename={}",
930 response.size(),
931 response.filename
932 );
933
934 PdfResponder {
935 data: response.data,
936 filename: response.filename,
937 force_download: response.force_download,
938 }
939}
940
941/// Build error responder from service error.
942fn build_error_response(error: PdfServiceError) -> ErrorResponder {
943 let status = match error.status_code() {
944 400 => Status::BadRequest,
945 502 => Status::BadGateway,
946 503 => Status::ServiceUnavailable,
947 504 => Status::GatewayTimeout,
948 _ => Status::InternalServerError,
949 };
950
951 log::warn!("PDF generation error: {} (HTTP {})", error, status.code);
952
953 ErrorResponder {
954 status,
955 body: ErrorResponse::from(error),
956 }
957}
958
959// ============================================================================
960// Extension Trait
961// ============================================================================
962
963/// Extension trait for `BrowserPool` with Rocket helpers.
964///
965/// Provides convenient methods for integrating with Rocket's managed state.
966///
967/// # Example
968///
969/// ```rust,ignore
970/// use html2pdf_api::integrations::rocket::BrowserPoolRocketExt;
971///
972/// let pool = BrowserPool::builder()
973/// .factory(Box::new(ChromeBrowserFactory::with_defaults()))
974/// .build()?;
975///
976/// pool.warmup().await?;
977///
978/// // Convert directly to Rocket managed state
979/// let shared_pool = pool.into_rocket_data();
980///
981/// rocket::build()
982/// .manage(shared_pool)
983/// .configure(configure_routes)
984/// ```
985pub trait BrowserPoolRocketExt {
986 /// Convert the pool into a shared reference suitable for Rocket's managed state.
987 ///
988 /// This is equivalent to calling `into_shared()`, returning an
989 /// `Arc<Mutex<BrowserPool>>` that can be passed to `rocket.manage()`.
990 ///
991 /// # Example
992 ///
993 /// ```rust,ignore
994 /// use html2pdf_api::integrations::rocket::BrowserPoolRocketExt;
995 ///
996 /// let pool = BrowserPool::builder()
997 /// .factory(Box::new(ChromeBrowserFactory::with_defaults()))
998 /// .build()?;
999 ///
1000 /// let shared_pool = pool.into_rocket_data();
1001 ///
1002 /// rocket::build()
1003 /// .manage(shared_pool)
1004 /// .mount("/", routes())
1005 /// ```
1006 fn into_rocket_data(self) -> SharedBrowserPool;
1007}
1008
1009impl BrowserPoolRocketExt for BrowserPool {
1010 fn into_rocket_data(self) -> SharedBrowserPool {
1011 self.into_shared()
1012 }
1013}
1014
1015// ============================================================================
1016// Helper Functions
1017// ============================================================================
1018
1019/// Create Rocket managed state from an existing shared pool.
1020///
1021/// Use this when you already have a `SharedBrowserPool` and want to
1022/// use it with Rocket's `manage()`.
1023///
1024/// # Parameters
1025///
1026/// * `pool` - The shared browser pool.
1027///
1028/// # Returns
1029///
1030/// `SharedBrowserPool` ready for use with `rocket.manage()`.
1031///
1032/// # Example
1033///
1034/// ```rust,ignore
1035/// use html2pdf_api::integrations::rocket::create_pool_data;
1036///
1037/// let shared_pool = pool.into_shared();
1038/// let pool_data = create_pool_data(shared_pool);
1039///
1040/// rocket::build().manage(pool_data)
1041/// ```
1042pub fn create_pool_data(pool: SharedBrowserPool) -> SharedBrowserPool {
1043 pool
1044}
1045
1046/// Create Rocket managed state from an `Arc` reference.
1047///
1048/// Use this when you need to keep a reference to the pool for shutdown.
1049///
1050/// # Parameters
1051///
1052/// * `pool` - Arc reference to the shared browser pool.
1053///
1054/// # Returns
1055///
1056/// Cloned `SharedBrowserPool` ready for use with `rocket.manage()`.
1057///
1058/// # Example
1059///
1060/// ```rust,ignore
1061/// use html2pdf_api::integrations::rocket::create_pool_data_from_arc;
1062///
1063/// let shared_pool = pool.into_shared();
1064/// let pool_for_shutdown = Arc::clone(&shared_pool);
1065/// let pool_data = create_pool_data_from_arc(shared_pool);
1066///
1067/// // Use pool_data in rocket.manage()
1068/// // Use pool_for_shutdown for cleanup in shutdown fairing
1069/// ```
1070pub fn create_pool_data_from_arc(pool: Arc<Mutex<BrowserPool>>) -> SharedBrowserPool {
1071 pool
1072}
1073
1074// ============================================================================
1075// Tests
1076// ============================================================================
1077
1078#[cfg(test)]
1079mod tests {
1080 use super::*;
1081
1082 #[test]
1083 fn test_type_alias_compiles() {
1084 // Verify the type alias is valid
1085 fn _accepts_shared_pool(_: SharedPool) {}
1086 }
1087
1088 #[test]
1089 fn test_error_responder_status_mapping() {
1090 let test_cases = vec![
1091 (
1092 PdfServiceError::InvalidUrl("".to_string()),
1093 Status::BadRequest,
1094 ),
1095 (
1096 PdfServiceError::NavigationFailed("".to_string()),
1097 Status::BadGateway,
1098 ),
1099 (
1100 PdfServiceError::BrowserUnavailable("".to_string()),
1101 Status::ServiceUnavailable,
1102 ),
1103 (
1104 PdfServiceError::Timeout("".to_string()),
1105 Status::GatewayTimeout,
1106 ),
1107 (
1108 PdfServiceError::Internal("".to_string()),
1109 Status::InternalServerError,
1110 ),
1111 ];
1112
1113 for (error, expected_status) in test_cases {
1114 let responder = build_error_response(error);
1115 assert_eq!(responder.status, expected_status);
1116 }
1117 }
1118
1119 #[test]
1120 fn test_pdf_from_url_query_conversion() {
1121 let query = PdfFromUrlQuery {
1122 url: "https://example.com".to_string(),
1123 filename: Some("test.pdf".to_string()),
1124 waitsecs: Some(10),
1125 landscape: Some(true),
1126 download: Some(false),
1127 print_background: Some(true),
1128 };
1129
1130 let request: PdfFromUrlRequest = query.into();
1131
1132 assert_eq!(request.url, "https://example.com");
1133 assert_eq!(request.filename, Some("test.pdf".to_string()));
1134 assert_eq!(request.waitsecs, Some(10));
1135 assert_eq!(request.landscape, Some(true));
1136 assert_eq!(request.download, Some(false));
1137 assert_eq!(request.print_background, Some(true));
1138 }
1139
1140 #[tokio::test]
1141 async fn test_shared_pool_type_matches() {
1142 // SharedPool and SharedBrowserPool should be compatible
1143 fn _takes_shared_pool(_: SharedPool) {}
1144 fn _returns_shared_browser_pool() -> SharedBrowserPool {
1145 Arc::new(Mutex::new(
1146 BrowserPool::builder()
1147 .factory(Box::new(crate::factory::mock::MockBrowserFactory::new()))
1148 .build()
1149 .unwrap(),
1150 ))
1151 }
1152
1153 // This should compile, proving type compatibility
1154 let pool: SharedBrowserPool = _returns_shared_browser_pool();
1155 let _: SharedPool = pool;
1156 }
1157
1158 #[test]
1159 fn test_routes_returns_all_endpoints() {
1160 let all_routes = routes();
1161 assert_eq!(all_routes.len(), 5);
1162 }
1163}