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.3", 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}