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;
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<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 browser = pool.get().unwrap();
241///     // ...
242/// }
243/// ```
244pub type BrowserPoolState = State<SharedBrowserPool>;
245
246// ============================================================================
247// Pre-built Handlers
248// ============================================================================
249
250/// Generate PDF from a URL.
251///
252/// This handler converts a web page to PDF using the browser pool.
253///
254/// # Endpoint
255///
256/// ```text
257/// GET /pdf?url=https://example.com&filename=output.pdf
258/// ```
259///
260/// # Usage in App
261///
262/// ```rust,ignore
263/// Router::new().route("/pdf", get(pdf_from_url)).with_state(pool)
264/// ```
265pub async fn pdf_from_url(
266    State(pool): State<SharedPool>,
267    Query(request): Query<PdfFromUrlRequest>,
268) -> Response {
269    let pool_arc = Arc::clone(&pool);
270
271    log::debug!("PDF from URL request: {}", request.url);
272
273    // Run blocking PDF generation with timeout
274    let result = tokio::time::timeout(
275        Duration::from_secs(DEFAULT_TIMEOUT_SECS),
276        tokio::task::spawn_blocking(move || service::generate_pdf_from_url(&pool_arc, &request)),
277    )
278    .await;
279
280    match result {
281        Ok(Ok(Ok(response))) => build_pdf_response(response),
282        Ok(Ok(Err(e))) => build_error_response(e),
283        Ok(Err(join_err)) => {
284            log::error!("Blocking task error: {}", join_err);
285            build_error_response(PdfServiceError::Internal(join_err.to_string()))
286        }
287        Err(_timeout) => {
288            log::error!(
289                "PDF generation timed out after {} seconds",
290                DEFAULT_TIMEOUT_SECS
291            );
292            build_error_response(PdfServiceError::Timeout(format!(
293                "Operation timed out after {} seconds",
294                DEFAULT_TIMEOUT_SECS
295            )))
296        }
297    }
298}
299
300/// Generate PDF from HTML content.
301///
302/// This handler converts HTML content directly to PDF without requiring
303/// a web server to host the HTML.
304///
305/// # Endpoint
306///
307/// ```text
308/// POST /pdf/html
309/// Content-Type: application/json
310/// ```
311///
312/// # Usage in App
313///
314/// ```rust,ignore
315/// Router::new().route("/pdf/html", post(pdf_from_html)).with_state(pool)
316/// ```
317pub async fn pdf_from_html(
318    State(pool): State<SharedPool>,
319    Json(request): Json<PdfFromHtmlRequest>,
320) -> Response {
321    let pool_arc = Arc::clone(&pool);
322
323    log::debug!("PDF from HTML request: {} bytes", request.html.len());
324
325    let result = tokio::time::timeout(
326        Duration::from_secs(DEFAULT_TIMEOUT_SECS),
327        tokio::task::spawn_blocking(move || service::generate_pdf_from_html(&pool_arc, &request)),
328    )
329    .await;
330
331    match result {
332        Ok(Ok(Ok(response))) => build_pdf_response(response),
333        Ok(Ok(Err(e))) => build_error_response(e),
334        Ok(Err(join_err)) => {
335            log::error!("Blocking task error: {}", join_err);
336            build_error_response(PdfServiceError::Internal(join_err.to_string()))
337        }
338        Err(_timeout) => {
339            log::error!("PDF generation timed out");
340            build_error_response(PdfServiceError::Timeout(format!(
341                "Operation timed out after {} seconds",
342                DEFAULT_TIMEOUT_SECS
343            )))
344        }
345    }
346}
347
348/// Get browser pool statistics.
349///
350/// Returns real-time metrics about the browser pool including available
351/// browsers, active browsers, and total count.
352///
353/// # Endpoint
354///
355/// ```text
356/// GET /pool/stats
357/// ```
358pub async fn pool_stats(State(pool): State<SharedPool>) -> Response {
359    match service::get_pool_stats(&pool) {
360        Ok(stats) => Json(stats).into_response(),
361        Err(e) => build_error_response(e),
362    }
363}
364
365/// Health check endpoint.
366///
367/// Simple endpoint that returns 200 OK if the service is running.
368/// Does not check pool health - use [`readiness_check`] for that.
369///
370/// # Endpoint
371///
372/// ```text
373/// GET /health
374/// ```
375pub async fn health_check() -> Response {
376    Json(HealthResponse::default()).into_response()
377}
378
379/// Readiness check endpoint.
380///
381/// Returns 200 OK if the pool has capacity to handle requests,
382/// 503 Service Unavailable otherwise.
383///
384/// # Endpoint
385///
386/// ```text
387/// GET /ready
388/// ```
389pub async fn readiness_check(State(pool): State<SharedPool>) -> Response {
390    match service::is_pool_ready(&pool) {
391        Ok(true) => Json(serde_json::json!({ "status": "ready" })).into_response(),
392        Ok(false) => {
393            let body = Json(serde_json::json!({
394                "status": "not_ready",
395                "reason": "no_available_capacity"
396            }));
397            (StatusCode::SERVICE_UNAVAILABLE, body).into_response()
398        }
399        Err(e) => {
400            let body = Json(ErrorResponse::from(e));
401            (StatusCode::SERVICE_UNAVAILABLE, body).into_response()
402        }
403    }
404}
405
406// ============================================================================
407// Route Configuration
408// ============================================================================
409
410/// Returns a router configured with all PDF routes.
411///
412/// Provides all pre-built handlers ready to be merged into a main router.
413/// This is the easiest way to set up the PDF service in Axum.
414///
415/// # Routes Added
416///
417/// | Method | Path | Handler | Description |
418/// |--------|------|---------|-------------|
419/// | GET | `/pdf` | [`pdf_from_url`] | Convert URL to PDF |
420/// | POST | `/pdf/html` | [`pdf_from_html`] | Convert HTML to PDF |
421/// | GET | `/pool/stats` | [`pool_stats`] | Pool statistics |
422/// | GET | `/health` | [`health_check`] | Health check |
423/// | GET | `/ready` | [`readiness_check`] | Readiness check |
424///
425/// # Example
426///
427/// ```rust,ignore
428/// use axum::Router;
429/// use html2pdf_api::integrations::axum::configure_routes;
430///
431/// let app = Router::new()
432///     .merge(configure_routes())
433///     .with_state(pool);
434/// ```
435pub fn configure_routes() -> Router<SharedPool> {
436    Router::new()
437        .route("/pdf", get(pdf_from_url))
438        .route("/pdf/html", post(pdf_from_html))
439        .route("/pool/stats", get(pool_stats))
440        .route("/health", get(health_check))
441        .route("/ready", get(readiness_check))
442}
443
444// ============================================================================
445// Response Builders (Internal)
446// ============================================================================
447
448/// Build HTTP response for successful PDF generation.
449fn build_pdf_response(response: PdfResponse) -> Response {
450    log::info!(
451        "PDF generated successfully: {} bytes, filename={}",
452        response.size(),
453        response.filename
454    );
455
456    let content_disposition = response.content_disposition();
457    let mut res = response.data.into_response();
458
459    res.headers_mut().insert(
460        header::CONTENT_TYPE,
461        HeaderValue::from_static("application/pdf"),
462    );
463    res.headers_mut()
464        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
465
466    if let Ok(val) = HeaderValue::from_str(&content_disposition) {
467        res.headers_mut().insert(header::CONTENT_DISPOSITION, val);
468    }
469
470    res
471}
472
473/// Build HTTP response for errors.
474fn build_error_response(error: PdfServiceError) -> Response {
475    let status_code =
476        StatusCode::from_u16(error.status_code()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
477    let body = ErrorResponse::from(&error);
478
479    log::warn!("PDF generation error: {} (HTTP {})", error, status_code);
480
481    (status_code, Json(body)).into_response()
482}
483
484// ============================================================================
485// Extension Traits
486// ============================================================================
487
488/// Extension trait for `BrowserPool` with Axum helpers.
489///
490/// Provides convenient methods for integrating with Axum.
491pub trait BrowserPoolAxumExt {
492    /// Convert the pool into a form suitable for Axum's `with_state()`.
493    ///
494    /// # Example
495    ///
496    /// ```rust,ignore
497    /// let state = pool.into_axum_state();
498    /// Router::new().route("/pdf", get(generate_pdf)).with_state(state)
499    /// ```
500    fn into_axum_state(self) -> SharedBrowserPool;
501
502    /// Convert the pool into an Extension layer.
503    fn into_axum_extension(self) -> axum::Extension<SharedBrowserPool>;
504}
505
506impl BrowserPoolAxumExt for BrowserPool {
507    fn into_axum_state(self) -> SharedBrowserPool {
508        self.into_shared()
509    }
510
511    fn into_axum_extension(self) -> axum::Extension<SharedBrowserPool> {
512        axum::Extension(self.into_shared())
513    }
514}
515
516/// Create an Axum Extension from an existing shared pool.
517pub fn create_extension(pool: SharedBrowserPool) -> axum::Extension<SharedBrowserPool> {
518    axum::Extension(pool)
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn test_type_alias_compiles() {
527        // This test just verifies the type alias is valid
528        fn _accepts_pool_state(_: BrowserPoolState) {}
529    }
530}