oxidize_pdf_api/
api.rs

1use axum::{
2    extract::{Json, Multipart},
3    http::StatusCode,
4    response::{IntoResponse, Response},
5    routing::{get, post},
6    Router,
7};
8use oxidize_pdf::{
9    operations::{merge_pdfs, MergeInput, MergeOptions},
10    parser::{PdfDocument, PdfReader},
11    Document, Font, Page,
12};
13use serde::{Deserialize, Serialize};
14use std::io::Cursor;
15use tempfile::NamedTempFile;
16use tower_http::cors::CorsLayer;
17
18/// Request payload for PDF creation endpoint
19#[derive(Debug, Deserialize)]
20pub struct CreatePdfRequest {
21    /// Text content to include in the PDF
22    pub text: String,
23    /// Font size in points (defaults to 24.0 if not specified)
24    pub font_size: Option<f64>,
25}
26
27/// Standard error response structure
28#[derive(Debug, Serialize, Deserialize)]
29pub struct ErrorResponse {
30    /// Human-readable error message describing what went wrong
31    pub error: String,
32}
33
34/// Response for text extraction endpoint
35#[derive(Debug, Serialize, Deserialize)]
36pub struct ExtractTextResponse {
37    /// Extracted text from the PDF
38    pub text: String,
39    /// Number of pages processed
40    pub pages: usize,
41}
42
43/// Request for PDF merge operation
44#[derive(Debug, Deserialize)]
45pub struct MergePdfRequest {
46    /// Options for merging PDFs
47    pub preserve_bookmarks: Option<bool>,
48    /// Whether to optimize the output
49    pub optimize: Option<bool>,
50}
51
52/// Response for PDF merge operation
53#[derive(Debug, Serialize)]
54pub struct MergePdfResponse {
55    /// Success message
56    pub message: String,
57    /// Number of PDFs merged
58    pub files_merged: usize,
59    /// Output file size in bytes
60    pub output_size: usize,
61}
62
63/// Application-specific error types for the API
64#[derive(Debug)]
65pub enum AppError {
66    /// PDF library errors (generation, parsing, etc.)
67    Pdf(oxidize_pdf::PdfError),
68    /// I/O errors (file operations, network, etc.)
69    Io(std::io::Error),
70    /// Operation errors from oxidize-pdf operations
71    Operation(String),
72}
73
74impl IntoResponse for AppError {
75    fn into_response(self) -> Response {
76        let error_msg = match self {
77            AppError::Pdf(e) => e.to_string(),
78            AppError::Io(e) => e.to_string(),
79            AppError::Operation(e) => e,
80        };
81
82        let error_response = ErrorResponse { error: error_msg };
83
84        (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)).into_response()
85    }
86}
87
88impl From<oxidize_pdf::PdfError> for AppError {
89    fn from(err: oxidize_pdf::PdfError) -> Self {
90        AppError::Pdf(err)
91    }
92}
93
94impl From<std::io::Error> for AppError {
95    fn from(err: std::io::Error) -> Self {
96        AppError::Io(err)
97    }
98}
99
100/// Build the application router with all routes configured
101pub fn app() -> Router {
102    Router::new()
103        // Core operations
104        .route("/api/create", post(create_pdf))
105        .route("/api/health", get(health_check))
106        .route("/api/extract", post(extract_text))
107        // PDF operations
108        .route("/api/merge", post(merge_pdfs_handler))
109        .layer(CorsLayer::permissive())
110}
111
112/// Create a PDF document from the provided text content
113pub async fn create_pdf(Json(payload): Json<CreatePdfRequest>) -> Result<Response, AppError> {
114    let mut doc = Document::new();
115    let mut page = Page::a4();
116
117    let font_size = payload.font_size.unwrap_or(24.0);
118
119    page.text()
120        .set_font(Font::Helvetica, font_size)
121        .at(50.0, 750.0)
122        .write(&payload.text)?;
123
124    doc.add_page(page);
125
126    // Generate PDF directly to buffer
127    let mut pdf_bytes = Vec::new();
128    doc.write(&mut pdf_bytes)?;
129
130    Ok((
131        StatusCode::OK,
132        [
133            ("Content-Type", "application/pdf"),
134            (
135                "Content-Disposition",
136                "attachment; filename=\"generated.pdf\"",
137            ),
138        ],
139        pdf_bytes,
140    )
141        .into_response())
142}
143
144/// Health check endpoint for monitoring and load balancing
145pub async fn health_check() -> impl IntoResponse {
146    Json(serde_json::json!({
147        "status": "ok",
148        "service": "oxidizePdf API",
149        "version": env!("CARGO_PKG_VERSION"),
150    }))
151}
152
153/// Extract text from an uploaded PDF file
154pub async fn extract_text(mut multipart: Multipart) -> Result<Response, AppError> {
155    let mut pdf_data = None;
156
157    while let Some(field) = multipart.next_field().await.map_err(|e| {
158        AppError::Io(std::io::Error::new(
159            std::io::ErrorKind::InvalidData,
160            format!("Failed to read multipart field: {e}"),
161        ))
162    })? {
163        if field.name() == Some("file") {
164            pdf_data = Some(field.bytes().await.map_err(|e| {
165                AppError::Io(std::io::Error::new(
166                    std::io::ErrorKind::InvalidData,
167                    format!("Failed to read file data: {e}"),
168                ))
169            })?);
170            break;
171        }
172    }
173
174    let pdf_bytes = pdf_data.ok_or_else(|| {
175        AppError::Io(std::io::Error::new(
176            std::io::ErrorKind::InvalidInput,
177            "No file provided in upload",
178        ))
179    })?;
180
181    // Parse PDF and extract text
182    let cursor = Cursor::new(pdf_bytes.as_ref());
183    let reader = PdfReader::new(cursor).map_err(|e| {
184        AppError::Io(std::io::Error::new(
185            std::io::ErrorKind::InvalidData,
186            format!("Failed to parse PDF: {e:?}"),
187        ))
188    })?;
189    let doc = PdfDocument::new(reader);
190
191    let extracted_texts = doc.extract_text().map_err(|e| {
192        AppError::Io(std::io::Error::new(
193            std::io::ErrorKind::InvalidData,
194            format!("Failed to extract text: {e:?}"),
195        ))
196    })?;
197
198    // Combine all extracted text
199    let text = extracted_texts
200        .into_iter()
201        .map(|et| et.text)
202        .collect::<Vec<_>>()
203        .join("\n");
204
205    let page_count = doc.page_count().unwrap_or(0) as usize;
206
207    let response = ExtractTextResponse {
208        text,
209        pages: page_count,
210    };
211
212    Ok((StatusCode::OK, Json(response)).into_response())
213}
214
215/// Merge multiple PDF files into a single PDF
216pub async fn merge_pdfs_handler(mut multipart: Multipart) -> Result<Response, AppError> {
217    let mut pdf_files = Vec::new();
218    let mut merge_options = MergeOptions::default();
219
220    // Parse multipart form
221    while let Some(field) = multipart.next_field().await.map_err(|e| {
222        AppError::Io(std::io::Error::new(
223            std::io::ErrorKind::InvalidData,
224            format!("Failed to read multipart field: {e}"),
225        ))
226    })? {
227        let field_name = field.name().unwrap_or("").to_string();
228
229        if field_name == "files" || field_name == "files[]" {
230            let file_data = field.bytes().await.map_err(|e| {
231                AppError::Io(std::io::Error::new(
232                    std::io::ErrorKind::InvalidData,
233                    format!("Failed to read file data: {e}"),
234                ))
235            })?;
236            pdf_files.push(file_data);
237        } else if field_name == "options" {
238            let options_text = field.text().await.map_err(|e| {
239                AppError::Io(std::io::Error::new(
240                    std::io::ErrorKind::InvalidData,
241                    format!("Failed to read options: {e}"),
242                ))
243            })?;
244
245            if let Ok(request) = serde_json::from_str::<MergePdfRequest>(&options_text) {
246                if let Some(preserve_bookmarks) = request.preserve_bookmarks {
247                    merge_options.preserve_bookmarks = preserve_bookmarks;
248                }
249                if let Some(optimize) = request.optimize {
250                    merge_options.optimize = optimize;
251                }
252            }
253        }
254    }
255
256    if pdf_files.len() < 2 {
257        return Err(AppError::Io(std::io::Error::new(
258            std::io::ErrorKind::InvalidInput,
259            "At least 2 PDF files are required for merging",
260        )));
261    }
262
263    // Create temporary files for input PDFs
264    let mut temp_files = Vec::new();
265    let mut merge_inputs = Vec::new();
266
267    for (i, file_data) in pdf_files.iter().enumerate() {
268        let temp_file = NamedTempFile::new().map_err(|e| {
269            AppError::Io(std::io::Error::other(format!(
270                "Failed to create temp file {i}: {e}"
271            )))
272        })?;
273
274        std::fs::write(temp_file.path(), file_data).map_err(|e| {
275            AppError::Io(std::io::Error::other(format!(
276                "Failed to write temp file {i}: {e}"
277            )))
278        })?;
279
280        merge_inputs.push(MergeInput::new(temp_file.path()));
281        temp_files.push(temp_file);
282    }
283
284    // Create temporary output file
285    let output_temp_file = NamedTempFile::new().map_err(|e| {
286        AppError::Io(std::io::Error::other(format!(
287            "Failed to create output temp file: {e}"
288        )))
289    })?;
290
291    // Perform merge
292    merge_pdfs(merge_inputs, output_temp_file.path(), merge_options)
293        .map_err(|e| AppError::Operation(format!("Failed to merge PDFs: {e}")))?;
294
295    // Read output file
296    let output_data = std::fs::read(output_temp_file.path()).map_err(|e| {
297        AppError::Io(std::io::Error::other(format!(
298            "Failed to read output file: {e}"
299        )))
300    })?;
301
302    let response = MergePdfResponse {
303        message: "PDFs merged successfully".to_string(),
304        files_merged: pdf_files.len(),
305        output_size: output_data.len(),
306    };
307
308    Ok((
309        StatusCode::OK,
310        [
311            ("Content-Type", "application/pdf"),
312            ("Content-Disposition", "attachment; filename=\"merged.pdf\""),
313            ("X-Merge-Info", &serde_json::to_string(&response).unwrap()),
314        ],
315        output_data,
316    )
317        .into_response())
318}