Skip to main content

html2pdf_api/integrations/
axum.rs

1//! Axum framework integration.
2//!
3//! This module provides helpers and pre-built handlers for using `BrowserPool`
4//! with Axum. 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 axum::Router;
15//! use html2pdf_api::prelude::*;
16//!
17//! #[tokio::main]
18//! async fn main() {
19//!     let pool = init_browser_pool().await
20//!         .expect("Failed to initialize browser pool");
21//!
22//!     let app = Router::new()
23//!         .merge(html2pdf_api::integrations::axum::configure_routes())
24//!         .with_state(pool);
25//!
26//!     let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
27//!     axum::serve(listener, app).await.unwrap();
28//! }
29//! ```
30//!
31//! This gives you the following endpoints:
32//!
33//! | Method | Path | Description |
34//! |--------|------|-------------|
35//! | GET | `/pdf?url=...` | Convert URL to PDF |
36//! | POST | `/pdf/html` | Convert HTML to PDF |
37//! | GET | `/pool/stats` | Pool statistics |
38//! | GET | `/health` | Health check |
39//! | GET | `/ready` | Readiness check |
40//!
41//! ## Option 2: Mix Pre-built and Custom Handlers
42//!
43//! Use individual pre-built handlers alongside your own:
44//!
45//! ```rust,ignore
46//! use axum::{Router, routing::get};
47//! use html2pdf_api::prelude::*;
48//! use html2pdf_api::integrations::axum::{pdf_from_url, health_check};
49//!
50//! async fn my_custom_handler() -> &'static str {
51//!     "Custom response"
52//! }
53//!
54//! #[tokio::main]
55//! async fn main() {
56//!     let pool = init_browser_pool().await.unwrap();
57//!
58//!     let app = Router::new()
59//!         .route("/pdf", get(pdf_from_url))
60//!         .route("/health", get(health_check))
61//!         .route("/custom", get(my_custom_handler))
62//!         .with_state(pool);
63//!
64//!     // ... serve app
65//! }
66//! ```
67//!
68//! ## Option 3: Custom Handlers with Service Functions
69//!
70//! For full control, use the service functions directly:
71//!
72//! ```rust,ignore
73//! use axum::{
74//!     extract::{Query, State},
75//!     http::StatusCode,
76//!     response::IntoResponse,
77//! };
78//! use html2pdf_api::prelude::*;
79//! use html2pdf_api::service::{generate_pdf_from_url, PdfFromUrlRequest};
80//!
81//! async fn my_pdf_handler(
82//!     State(pool): State<SharedBrowserPool>,
83//!     Query(request): Query<PdfFromUrlRequest>,
84//! ) -> impl IntoResponse {
85//!     // Call service in blocking context
86//!     let result = tokio::task::spawn_blocking(move || {
87//!         generate_pdf_from_url(&pool, &request)
88//!     }).await;
89//!
90//!     match result {
91//!         Ok(Ok(pdf)) => {
92//!             // Custom post-processing
93//!             (
94//!                 [(axum::http::header::CONTENT_TYPE, "application/pdf")],
95//!                 pdf.data,
96//!             ).into_response()
97//!         }
98//!         Ok(Err(_)) => StatusCode::BAD_REQUEST.into_response(),
99//!         Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
100//!     }
101//! }
102//! ```
103//!
104//! ## Option 4: Full Manual Control (Original Approach)
105//!
106//! For complete control over browser operations:
107//!
108//! ```rust,ignore
109//! use axum::{extract::State, http::StatusCode, response::IntoResponse};
110//! use html2pdf_api::prelude::*;
111//!
112//! async fn manual_pdf_handler(
113//!     State(pool): State<SharedBrowserPool>,
114//! ) -> Result<impl IntoResponse, StatusCode> {
115//!     let pool_guard = pool.lock()
116//!         .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
117//!
118//!     let browser = pool_guard.get()
119//!         .map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?;
120//!
121//!     let tab = browser.new_tab()
122//!         .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
123//!     tab.navigate_to("https://example.com")
124//!         .map_err(|_| StatusCode::BAD_GATEWAY)?;
125//!     tab.wait_until_navigated()
126//!         .map_err(|_| StatusCode::BAD_GATEWAY)?;
127//!
128//!     let pdf_data = tab.print_to_pdf(None)
129//!         .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
130//!
131//!     Ok((
132//!         [(axum::http::header::CONTENT_TYPE, "application/pdf")],
133//!         pdf_data,
134//!     ))
135//! }
136//! ```
137//!
138//! # Setup
139//!
140//! Add to your `Cargo.toml`:
141//!
142//! ```toml
143//! [dependencies]
144//! html2pdf-api = { version = "0.2", features = ["axum-integration"] }
145//! axum = "0.8"
146//! ```
147//!
148//! # Graceful Shutdown
149//!
150//! For proper cleanup with graceful shutdown:
151//!
152//! ```rust,ignore
153//! use axum::Router;
154//! use html2pdf_api::prelude::*;
155//! use std::sync::Arc;
156//! use tokio::signal;
157//!
158//! #[tokio::main]
159//! async fn main() {
160//!     let pool = init_browser_pool().await.unwrap();
161//!     let shutdown_pool = Arc::clone(&pool);
162//!
163//!     let app = Router::new()
164//!         .merge(html2pdf_api::integrations::axum::configure_routes())
165//!         .with_state(pool);
166//!
167//!     let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
168//!     
169//!     axum::serve(listener, app)
170//!         .with_graceful_shutdown(shutdown_signal(shutdown_pool))
171//!         .await
172//!         .unwrap();
173//! }
174//!
175//! async fn shutdown_signal(pool: SharedBrowserPool) {
176//!     let ctrl_c = async {
177//!         signal::ctrl_c().await.expect("Failed to listen for ctrl+c");
178//!     };
179//!
180//!     #[cfg(unix)]
181//!     let terminate = async {
182//!         signal::unix::signal(signal::unix::SignalKind::terminate())
183//!             .expect("Failed to install signal handler")
184//!             .recv()
185//!             .await;
186//!     };
187//!
188//!     #[cfg(not(unix))]
189//!     let terminate = std::future::pending::<()>();
190//!
191//!     tokio::select! {
192//!         _ = ctrl_c => {},
193//!         _ = terminate => {},
194//!     }
195//!
196//!     println!("Shutting down...");
197//!     if let Ok(mut pool) = pool.lock() {
198//!         pool.shutdown();
199//!     }
200//! }
201//! ```
202
203use axum::{
204    Router,
205    extract::{Json, Query, State},
206    http::{
207        StatusCode,
208        header::{self, HeaderValue},
209    },
210    response::{IntoResponse, Response},
211    routing::{get, post},
212};
213use std::sync::{Arc, Mutex};
214use std::time::Duration;
215
216use crate::SharedBrowserPool;
217use crate::pool::BrowserPool;
218use crate::service::{
219    self, DEFAULT_TIMEOUT_SECS, ErrorResponse, HealthResponse, PdfFromHtmlRequest,
220    PdfFromUrlRequest, PdfResponse, PdfServiceError,
221};
222
223// ============================================================================
224// Type Aliases
225// ============================================================================
226
227/// Type alias for shared browser pool.
228///
229/// This is the standard pool type used by the service functions.
230pub type SharedPool = Arc<Mutex<BrowserPool>>;
231
232/// Type alias for Axum `State` extractor with the shared pool.
233///
234/// Use this type in your handler parameters:
235///
236/// ```rust,ignore
237/// async fn handler(
238///     BrowserPoolState(pool): BrowserPoolState,
239/// ) -> impl IntoResponse {
240///     let pool = pool.lock().unwrap();
241///     let browser = pool.get()?;
242///     // ...
243/// }
244/// ```
245pub type BrowserPoolState = State<SharedBrowserPool>;
246
247// ============================================================================
248// Pre-built Handlers
249// ============================================================================
250
251/// Generate PDF from a URL.
252///
253/// This handler converts a web page to PDF using the browser pool.
254///
255/// # Endpoint
256///
257/// ```text
258/// GET /pdf?url=https://example.com&filename=output.pdf
259/// ```
260///
261/// # Usage in App
262///
263/// ```rust,ignore
264/// Router::new().route("/pdf", get(pdf_from_url)).with_state(pool)
265/// ```
266pub async fn pdf_from_url(
267    State(pool): State<SharedPool>,
268    Query(request): Query<PdfFromUrlRequest>,
269) -> Response {
270    let pool_arc = Arc::clone(&pool);
271
272    log::debug!("PDF from URL request: {}", request.url);
273
274    // Run blocking PDF generation with timeout
275    let result = tokio::time::timeout(
276        Duration::from_secs(DEFAULT_TIMEOUT_SECS),
277        tokio::task::spawn_blocking(move || service::generate_pdf_from_url(&pool_arc, &request)),
278    )
279    .await;
280
281    match result {
282        Ok(Ok(Ok(response))) => build_pdf_response(response),
283        Ok(Ok(Err(e))) => build_error_response(e),
284        Ok(Err(join_err)) => {
285            log::error!("Blocking task error: {}", join_err);
286            build_error_response(PdfServiceError::Internal(join_err.to_string()))
287        }
288        Err(_timeout) => {
289            log::error!(
290                "PDF generation timed out after {} seconds",
291                DEFAULT_TIMEOUT_SECS
292            );
293            build_error_response(PdfServiceError::Timeout(format!(
294                "Operation timed out after {} seconds",
295                DEFAULT_TIMEOUT_SECS
296            )))
297        }
298    }
299}
300
301/// Generate PDF from HTML content.
302///
303/// This handler converts HTML content directly to PDF without requiring
304/// a web server to host the HTML.
305///
306/// # Endpoint
307///
308/// ```text
309/// POST /pdf/html
310/// Content-Type: application/json
311/// ```
312///
313/// # Usage in App
314///
315/// ```rust,ignore
316/// Router::new().route("/pdf/html", post(pdf_from_html)).with_state(pool)
317/// ```
318pub async fn pdf_from_html(
319    State(pool): State<SharedPool>,
320    Json(request): Json<PdfFromHtmlRequest>,
321) -> Response {
322    let pool_arc = Arc::clone(&pool);
323
324    log::debug!("PDF from HTML request: {} bytes", request.html.len());
325
326    let result = tokio::time::timeout(
327        Duration::from_secs(DEFAULT_TIMEOUT_SECS),
328        tokio::task::spawn_blocking(move || service::generate_pdf_from_html(&pool_arc, &request)),
329    )
330    .await;
331
332    match result {
333        Ok(Ok(Ok(response))) => build_pdf_response(response),
334        Ok(Ok(Err(e))) => build_error_response(e),
335        Ok(Err(join_err)) => {
336            log::error!("Blocking task error: {}", join_err);
337            build_error_response(PdfServiceError::Internal(join_err.to_string()))
338        }
339        Err(_timeout) => {
340            log::error!("PDF generation timed out");
341            build_error_response(PdfServiceError::Timeout(format!(
342                "Operation timed out after {} seconds",
343                DEFAULT_TIMEOUT_SECS
344            )))
345        }
346    }
347}
348
349/// Get browser pool statistics.
350///
351/// Returns real-time metrics about the browser pool including available
352/// browsers, active browsers, and total count.
353///
354/// # Endpoint
355///
356/// ```text
357/// GET /pool/stats
358/// ```
359pub async fn pool_stats(State(pool): State<SharedPool>) -> Response {
360    match service::get_pool_stats(&pool) {
361        Ok(stats) => Json(stats).into_response(),
362        Err(e) => build_error_response(e),
363    }
364}
365
366/// Health check endpoint.
367///
368/// Simple endpoint that returns 200 OK if the service is running.
369/// Does not check pool health - use [`readiness_check`] for that.
370///
371/// # Endpoint
372///
373/// ```text
374/// GET /health
375/// ```
376pub async fn health_check() -> Response {
377    Json(HealthResponse::default()).into_response()
378}
379
380/// Readiness check endpoint.
381///
382/// Returns 200 OK if the pool has capacity to handle requests,
383/// 503 Service Unavailable otherwise.
384///
385/// # Endpoint
386///
387/// ```text
388/// GET /ready
389/// ```
390pub async fn readiness_check(State(pool): State<SharedPool>) -> Response {
391    match service::is_pool_ready(&pool) {
392        Ok(true) => Json(serde_json::json!({ "status": "ready" })).into_response(),
393        Ok(false) => {
394            let body = Json(serde_json::json!({
395                "status": "not_ready",
396                "reason": "no_available_capacity"
397            }));
398            (StatusCode::SERVICE_UNAVAILABLE, body).into_response()
399        }
400        Err(e) => {
401            let body = Json(ErrorResponse::from(e));
402            (StatusCode::SERVICE_UNAVAILABLE, body).into_response()
403        }
404    }
405}
406
407// ============================================================================
408// Route Configuration
409// ============================================================================
410
411/// Returns a router configured with all PDF routes.
412///
413/// Provides all pre-built handlers ready to be merged into a main router.
414/// This is the easiest way to set up the PDF service in Axum.
415///
416/// # Routes Added
417///
418/// | Method | Path | Handler | Description |
419/// |--------|------|---------|-------------|
420/// | GET | `/pdf` | [`pdf_from_url`] | Convert URL to PDF |
421/// | POST | `/pdf/html` | [`pdf_from_html`] | Convert HTML to PDF |
422/// | GET | `/pool/stats` | [`pool_stats`] | Pool statistics |
423/// | GET | `/health` | [`health_check`] | Health check |
424/// | GET | `/ready` | [`readiness_check`] | Readiness check |
425///
426/// # Example
427///
428/// ```rust,ignore
429/// use axum::Router;
430/// use html2pdf_api::integrations::axum::configure_routes;
431///
432/// let app = Router::new()
433///     .merge(configure_routes())
434///     .with_state(pool);
435/// ```
436pub fn configure_routes() -> Router<SharedPool> {
437    Router::new()
438        .route("/pdf", get(pdf_from_url))
439        .route("/pdf/html", post(pdf_from_html))
440        .route("/pool/stats", get(pool_stats))
441        .route("/health", get(health_check))
442        .route("/ready", get(readiness_check))
443}
444
445// ============================================================================
446// Response Builders (Internal)
447// ============================================================================
448
449/// Build HTTP response for successful PDF generation.
450fn build_pdf_response(response: PdfResponse) -> Response {
451    log::info!(
452        "PDF generated successfully: {} bytes, filename={}",
453        response.size(),
454        response.filename
455    );
456
457    let content_disposition = response.content_disposition();
458    let mut res = response.data.into_response();
459
460    res.headers_mut().insert(
461        header::CONTENT_TYPE,
462        HeaderValue::from_static("application/pdf"),
463    );
464    res.headers_mut()
465        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
466
467    if let Ok(val) = HeaderValue::from_str(&content_disposition) {
468        res.headers_mut().insert(header::CONTENT_DISPOSITION, val);
469    }
470
471    res
472}
473
474/// Build HTTP response for errors.
475fn build_error_response(error: PdfServiceError) -> Response {
476    let status_code =
477        StatusCode::from_u16(error.status_code()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
478    let body = ErrorResponse::from(&error);
479
480    log::warn!("PDF generation error: {} (HTTP {})", error, status_code);
481
482    (status_code, Json(body)).into_response()
483}
484
485// ============================================================================
486// Extension Traits
487// ============================================================================
488
489/// Extension trait for `BrowserPool` with Axum helpers.
490///
491/// Provides convenient methods for integrating with Axum.
492pub trait BrowserPoolAxumExt {
493    /// Convert the pool into a form suitable for Axum's `with_state()`.
494    ///
495    /// # Example
496    ///
497    /// ```rust,ignore
498    /// let state = pool.into_axum_state();
499    /// Router::new().route("/pdf", get(generate_pdf)).with_state(state)
500    /// ```
501    fn into_axum_state(self) -> SharedBrowserPool;
502
503    /// Convert the pool into an Extension layer.
504    fn into_axum_extension(self) -> axum::Extension<SharedBrowserPool>;
505}
506
507impl BrowserPoolAxumExt for BrowserPool {
508    fn into_axum_state(self) -> SharedBrowserPool {
509        self.into_shared()
510    }
511
512    fn into_axum_extension(self) -> axum::Extension<SharedBrowserPool> {
513        axum::Extension(self.into_shared())
514    }
515}
516
517/// Create an Axum Extension from an existing shared pool.
518pub fn create_extension(pool: SharedBrowserPool) -> axum::Extension<SharedBrowserPool> {
519    axum::Extension(pool)
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    #[test]
527    fn test_type_alias_compiles() {
528        // This test just verifies the type alias is valid
529        fn _accepts_pool_state(_: BrowserPoolState) {}
530    }
531}