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}