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}