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