feat: Add Rust API Server with REST and GraphQL support
- Implement JWT authentication layer - Add Engine Registry with 20+ conversion engines - Implement Conversion Service with background job processing - REST API endpoints for file conversion operations - GraphQL API with queries and mutations - Comprehensive error handling with conversion suggestions - Integration tests for both REST and GraphQL APIs - Complete API documentation Features: - REST API: /api/v1/* endpoints - GraphQL API: /graphql endpoint with playground - JWT Bearer token authentication - Engine validation with alternative suggestions - File download via API (not exposing file paths) - Support for FFmpeg, ImageMagick, LibreOffice, Pandoc, and more
This commit is contained in:
parent
d0388066a5
commit
e083e5d11d
18 changed files with 5235 additions and 0 deletions
287
api-server/src/auth.rs
Normal file
287
api-server/src/auth.rs
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
//! JWT Authentication module
|
||||
//!
|
||||
//! Handles JWT token validation for API requests.
|
||||
|
||||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
http::{header::AUTHORIZATION, request::Parts, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Json, RequestPartsExt,
|
||||
};
|
||||
use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::error::{ApiError, ErrorResponse};
|
||||
|
||||
/// JWT Claims structure
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Claims {
|
||||
/// Subject (user ID)
|
||||
pub sub: String,
|
||||
/// Expiration time (Unix timestamp)
|
||||
pub exp: i64,
|
||||
/// Issued at (Unix timestamp)
|
||||
pub iat: i64,
|
||||
/// Optional user email
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
/// Optional user roles
|
||||
#[serde(default)]
|
||||
pub roles: Vec<String>,
|
||||
}
|
||||
|
||||
impl Claims {
|
||||
/// Create new claims for a user
|
||||
pub fn new(user_id: String, expiration_secs: i64) -> Self {
|
||||
let now = Utc::now().timestamp();
|
||||
Self {
|
||||
sub: user_id,
|
||||
exp: now + expiration_secs,
|
||||
iat: now,
|
||||
email: None,
|
||||
roles: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the token is expired
|
||||
pub fn is_expired(&self) -> bool {
|
||||
let now = Utc::now().timestamp();
|
||||
self.exp < now
|
||||
}
|
||||
|
||||
/// Get expiration as DateTime
|
||||
pub fn expiration(&self) -> Option<DateTime<Utc>> {
|
||||
DateTime::from_timestamp(self.exp, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticated user extracted from JWT
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthenticatedUser {
|
||||
pub user_id: String,
|
||||
pub email: Option<String>,
|
||||
pub roles: Vec<String>,
|
||||
pub claims: Claims,
|
||||
}
|
||||
|
||||
impl AuthenticatedUser {
|
||||
/// Check if user has a specific role
|
||||
pub fn has_role(&self, role: &str) -> bool {
|
||||
self.roles.iter().any(|r| r == role)
|
||||
}
|
||||
}
|
||||
|
||||
/// JWT Validator
|
||||
pub struct JwtValidator {
|
||||
decoding_key: DecodingKey,
|
||||
validation: Validation,
|
||||
}
|
||||
|
||||
impl JwtValidator {
|
||||
/// Create a new JWT validator with the given secret
|
||||
pub fn new(secret: &str) -> Self {
|
||||
let decoding_key = DecodingKey::from_secret(secret.as_bytes());
|
||||
let mut validation = Validation::new(Algorithm::HS256);
|
||||
validation.validate_exp = true;
|
||||
validation.validate_aud = false;
|
||||
|
||||
Self {
|
||||
decoding_key,
|
||||
validation,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a JWT token and return the claims
|
||||
pub fn validate(&self, token: &str) -> Result<Claims, ApiError> {
|
||||
let token_data = decode::<Claims>(token, &self.decoding_key, &self.validation)
|
||||
.map_err(|e| match e.kind() {
|
||||
jsonwebtoken::errors::ErrorKind::ExpiredSignature => ApiError::TokenExpired,
|
||||
jsonwebtoken::errors::ErrorKind::InvalidToken => {
|
||||
ApiError::InvalidToken("Token format is invalid".into())
|
||||
}
|
||||
jsonwebtoken::errors::ErrorKind::InvalidSignature => {
|
||||
ApiError::InvalidToken("Token signature is invalid".into())
|
||||
}
|
||||
_ => ApiError::InvalidToken(e.to_string()),
|
||||
})?;
|
||||
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
|
||||
/// Extract token from Authorization header
|
||||
pub fn extract_token(auth_header: &str) -> Result<&str, ApiError> {
|
||||
if !auth_header.starts_with("Bearer ") {
|
||||
return Err(ApiError::InvalidToken(
|
||||
"Authorization header must use Bearer scheme".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let token = auth_header.trim_start_matches("Bearer ").trim();
|
||||
if token.is_empty() {
|
||||
return Err(ApiError::InvalidToken("Token is empty".into()));
|
||||
}
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extractor for authenticated requests
|
||||
/// Use this in route handlers to require authentication
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RequireAuth(pub AuthenticatedUser);
|
||||
|
||||
impl<S> FromRequestParts<S> for RequireAuth
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = AuthError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
// Get the JWT secret from environment or use default
|
||||
let jwt_secret = std::env::var("JWT_SECRET")
|
||||
.unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string());
|
||||
|
||||
let validator = JwtValidator::new(&jwt_secret);
|
||||
|
||||
// Extract Authorization header
|
||||
let auth_header = parts
|
||||
.headers
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.ok_or(AuthError(ApiError::MissingAuthHeader))?;
|
||||
|
||||
// Extract and validate token
|
||||
let token = JwtValidator::extract_token(auth_header).map_err(AuthError)?;
|
||||
let claims = validator.validate(token).map_err(AuthError)?;
|
||||
|
||||
let user = AuthenticatedUser {
|
||||
user_id: claims.sub.clone(),
|
||||
email: claims.email.clone(),
|
||||
roles: claims.roles.clone(),
|
||||
claims,
|
||||
};
|
||||
|
||||
Ok(RequireAuth(user))
|
||||
}
|
||||
}
|
||||
|
||||
/// Auth error wrapper for proper response formatting
|
||||
pub struct AuthError(pub ApiError);
|
||||
|
||||
impl IntoResponse for AuthError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = self.0.status_code();
|
||||
let body = Json(self.0.to_error_response());
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a JWT token for testing purposes
|
||||
///
|
||||
/// Note: The API server does NOT generate tokens in production.
|
||||
/// This is only for testing and development.
|
||||
#[cfg(any(test, feature = "dev-utils"))]
|
||||
pub fn generate_test_token(secret: &str, user_id: &str, expiration_secs: i64) -> String {
|
||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||
|
||||
let claims = Claims::new(user_id.to_string(), expiration_secs);
|
||||
encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)
|
||||
.expect("Failed to generate test token")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const TEST_SECRET: &str = "test-secret-key-for-testing";
|
||||
|
||||
fn create_test_token(user_id: &str, exp_offset: i64) -> String {
|
||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||
|
||||
let now = Utc::now().timestamp();
|
||||
let claims = Claims {
|
||||
sub: user_id.to_string(),
|
||||
exp: now + exp_offset,
|
||||
iat: now,
|
||||
email: Some("test@example.com".into()),
|
||||
roles: vec!["user".into()],
|
||||
};
|
||||
|
||||
encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(TEST_SECRET.as_bytes()),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_token() {
|
||||
let validator = JwtValidator::new(TEST_SECRET);
|
||||
let token = create_test_token("user123", 3600);
|
||||
|
||||
let claims = validator.validate(&token).unwrap();
|
||||
assert_eq!(claims.sub, "user123");
|
||||
assert_eq!(claims.email, Some("test@example.com".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expired_token() {
|
||||
let validator = JwtValidator::new(TEST_SECRET);
|
||||
let token = create_test_token("user123", -3600); // Expired 1 hour ago
|
||||
|
||||
let result = validator.validate(&token);
|
||||
assert!(matches!(result, Err(ApiError::TokenExpired)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_signature() {
|
||||
let validator = JwtValidator::new("different-secret");
|
||||
let token = create_test_token("user123", 3600);
|
||||
|
||||
let result = validator.validate(&token);
|
||||
assert!(matches!(result, Err(ApiError::InvalidToken(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_token() {
|
||||
let header = "Bearer eyJhbGciOiJIUzI1NiJ9.test.signature";
|
||||
let token = JwtValidator::extract_token(header).unwrap();
|
||||
assert_eq!(token, "eyJhbGciOiJIUzI1NiJ9.test.signature");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_token_invalid_scheme() {
|
||||
let header = "Basic dXNlcjpwYXNz";
|
||||
let result = JwtValidator::extract_token(header);
|
||||
assert!(matches!(result, Err(ApiError::InvalidToken(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claims_is_expired() {
|
||||
let now = Utc::now().timestamp();
|
||||
|
||||
let valid_claims = Claims {
|
||||
sub: "user".into(),
|
||||
exp: now + 3600,
|
||||
iat: now,
|
||||
email: None,
|
||||
roles: vec![],
|
||||
};
|
||||
assert!(!valid_claims.is_expired());
|
||||
|
||||
let expired_claims = Claims {
|
||||
sub: "user".into(),
|
||||
exp: now - 3600,
|
||||
iat: now - 7200,
|
||||
email: None,
|
||||
roles: vec![],
|
||||
};
|
||||
assert!(expired_claims.is_expired());
|
||||
}
|
||||
}
|
||||
75
api-server/src/config.rs
Normal file
75
api-server/src/config.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
//! Configuration module for the API server
|
||||
|
||||
use std::env;
|
||||
use anyhow::Result;
|
||||
|
||||
/// Server configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
/// Server host address
|
||||
pub host: String,
|
||||
/// Server port
|
||||
pub port: u16,
|
||||
/// JWT secret key for token validation
|
||||
pub jwt_secret: String,
|
||||
/// Directory for uploaded files
|
||||
pub upload_dir: String,
|
||||
/// Directory for converted output files
|
||||
pub output_dir: String,
|
||||
/// Maximum file size in bytes (default: 100MB)
|
||||
pub max_file_size: usize,
|
||||
/// JWT token expiration time in seconds (for validation reference)
|
||||
pub jwt_expiration_secs: i64,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Load configuration from environment variables
|
||||
pub fn from_env() -> Result<Self> {
|
||||
Ok(Self {
|
||||
host: env::var("API_HOST").unwrap_or_else(|_| "0.0.0.0".to_string()),
|
||||
port: env::var("API_PORT")
|
||||
.unwrap_or_else(|_| "3001".to_string())
|
||||
.parse()?,
|
||||
jwt_secret: env::var("JWT_SECRET")
|
||||
.unwrap_or_else(|_| "your-super-secret-jwt-key-change-in-production".to_string()),
|
||||
upload_dir: env::var("UPLOAD_DIR")
|
||||
.unwrap_or_else(|_| "./data/uploads".to_string()),
|
||||
output_dir: env::var("OUTPUT_DIR")
|
||||
.unwrap_or_else(|_| "./data/output".to_string()),
|
||||
max_file_size: env::var("MAX_FILE_SIZE")
|
||||
.unwrap_or_else(|_| "104857600".to_string()) // 100MB
|
||||
.parse()?,
|
||||
jwt_expiration_secs: env::var("JWT_EXPIRATION_SECS")
|
||||
.unwrap_or_else(|_| "86400".to_string()) // 24 hours
|
||||
.parse()?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a configuration for testing
|
||||
#[cfg(test)]
|
||||
pub fn test_config() -> Self {
|
||||
Self {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 3001,
|
||||
jwt_secret: "test-secret-key".to_string(),
|
||||
upload_dir: "./test_data/uploads".to_string(),
|
||||
output_dir: "./test_data/output".to_string(),
|
||||
max_file_size: 10 * 1024 * 1024, // 10MB for tests
|
||||
jwt_expiration_secs: 3600,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
host: "0.0.0.0".to_string(),
|
||||
port: 3001,
|
||||
jwt_secret: "default-secret-change-me".to_string(),
|
||||
upload_dir: "./data/uploads".to_string(),
|
||||
output_dir: "./data/output".to_string(),
|
||||
max_file_size: 100 * 1024 * 1024,
|
||||
jwt_expiration_secs: 86400,
|
||||
}
|
||||
}
|
||||
}
|
||||
626
api-server/src/conversion.rs
Normal file
626
api-server/src/conversion.rs
Normal file
|
|
@ -0,0 +1,626 @@
|
|||
//! Conversion Service module
|
||||
//!
|
||||
//! Handles file conversion operations, job management, and process execution.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::process::Command;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::engine::EngineRegistry;
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::models::{ConversionJob, JobStatus};
|
||||
|
||||
/// Conversion service for managing jobs and executing conversions
|
||||
pub struct ConversionService {
|
||||
engine_registry: Arc<EngineRegistry>,
|
||||
config: Arc<Config>,
|
||||
/// In-memory job storage (in production, use a database)
|
||||
jobs: RwLock<HashMap<Uuid, ConversionJob>>,
|
||||
}
|
||||
|
||||
impl ConversionService {
|
||||
/// Create a new conversion service
|
||||
pub fn new(engine_registry: Arc<EngineRegistry>, config: Arc<Config>) -> Self {
|
||||
Self {
|
||||
engine_registry,
|
||||
config,
|
||||
jobs: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new conversion job
|
||||
pub async fn create_job(
|
||||
&self,
|
||||
user_id: String,
|
||||
original_filename: String,
|
||||
engine: String,
|
||||
target_format: String,
|
||||
options: Option<serde_json::Value>,
|
||||
file_data: Vec<u8>,
|
||||
) -> ApiResult<ConversionJob> {
|
||||
// Extract source format from filename
|
||||
let source_format = Path::new(&original_filename)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|s| s.to_lowercase())
|
||||
.ok_or_else(|| ApiError::InvalidFile("Cannot determine file format".into()))?;
|
||||
|
||||
// Validate the conversion is supported
|
||||
self.engine_registry
|
||||
.validate_conversion(&engine, &source_format, &target_format)?;
|
||||
|
||||
// Create the job
|
||||
let job = ConversionJob::new(
|
||||
user_id.clone(),
|
||||
original_filename.clone(),
|
||||
source_format.clone(),
|
||||
target_format.clone(),
|
||||
engine.clone(),
|
||||
options,
|
||||
);
|
||||
|
||||
// Create directories for this job
|
||||
let job_upload_dir = self.get_upload_path(&job.id);
|
||||
let job_output_dir = self.get_output_path(&job.id);
|
||||
|
||||
tokio::fs::create_dir_all(&job_upload_dir).await
|
||||
.map_err(|e| ApiError::InternalError(format!("Failed to create upload directory: {}", e)))?;
|
||||
tokio::fs::create_dir_all(&job_output_dir).await
|
||||
.map_err(|e| ApiError::InternalError(format!("Failed to create output directory: {}", e)))?;
|
||||
|
||||
// Save the uploaded file
|
||||
let input_path = job_upload_dir.join(&original_filename);
|
||||
tokio::fs::write(&input_path, &file_data).await
|
||||
.map_err(|e| ApiError::InternalError(format!("Failed to save uploaded file: {}", e)))?;
|
||||
|
||||
// Store the job
|
||||
{
|
||||
let mut jobs = self.jobs.write().await;
|
||||
jobs.insert(job.id, job.clone());
|
||||
}
|
||||
|
||||
// Start conversion in background
|
||||
let job_id = job.id;
|
||||
let service = self.clone_for_task();
|
||||
tokio::spawn(async move {
|
||||
service.execute_conversion(job_id).await;
|
||||
});
|
||||
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
/// Clone the service for use in a spawned task
|
||||
fn clone_for_task(&self) -> ConversionServiceTask {
|
||||
ConversionServiceTask {
|
||||
engine_registry: self.engine_registry.clone(),
|
||||
config: self.config.clone(),
|
||||
jobs: unsafe {
|
||||
// Safe because we're only reading from the parent's jobs
|
||||
std::mem::transmute::<&RwLock<HashMap<Uuid, ConversionJob>>, &'static RwLock<HashMap<Uuid, ConversionJob>>>(&self.jobs)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute the conversion for a job
|
||||
async fn execute_conversion_internal(&self, job_id: Uuid) -> Result<(), String> {
|
||||
// Update job status to processing
|
||||
let job = {
|
||||
let mut jobs = self.jobs.write().await;
|
||||
let job = jobs.get_mut(&job_id).ok_or("Job not found")?;
|
||||
job.set_processing();
|
||||
job.clone()
|
||||
};
|
||||
|
||||
let input_path = self.get_upload_path(&job_id).join(&job.original_filename);
|
||||
let output_filename = format!(
|
||||
"{}.{}",
|
||||
Path::new(&job.original_filename)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("output"),
|
||||
job.target_format
|
||||
);
|
||||
let output_path = self.get_output_path(&job_id).join(&output_filename);
|
||||
|
||||
// Execute the conversion based on engine
|
||||
let result = self
|
||||
.run_converter(&job.engine, &input_path, &output_path, &job.source_format, &job.target_format)
|
||||
.await;
|
||||
|
||||
// Update job status based on result
|
||||
let mut jobs = self.jobs.write().await;
|
||||
if let Some(job) = jobs.get_mut(&job_id) {
|
||||
match result {
|
||||
Ok(_) => {
|
||||
job.set_completed(output_filename);
|
||||
}
|
||||
Err(e) => {
|
||||
job.set_failed(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the appropriate converter command
|
||||
async fn run_converter(
|
||||
&self,
|
||||
engine: &str,
|
||||
input_path: &Path,
|
||||
output_path: &Path,
|
||||
_from: &str,
|
||||
_to: &str,
|
||||
) -> Result<(), String> {
|
||||
let input = input_path.to_string_lossy().to_string();
|
||||
let output = output_path.to_string_lossy().to_string();
|
||||
|
||||
let (program, args) = match engine {
|
||||
"ffmpeg" => (
|
||||
"ffmpeg",
|
||||
vec!["-i".to_string(), input, "-y".to_string(), output],
|
||||
),
|
||||
"imagemagick" => (
|
||||
"magick",
|
||||
vec!["convert".to_string(), input, output],
|
||||
),
|
||||
"graphicsmagick" => (
|
||||
"gm",
|
||||
vec!["convert".to_string(), input, output],
|
||||
),
|
||||
"libreoffice" => {
|
||||
let output_dir = output_path.parent().unwrap().to_string_lossy().to_string();
|
||||
(
|
||||
"libreoffice",
|
||||
vec![
|
||||
"--headless".to_string(),
|
||||
"--convert-to".to_string(),
|
||||
_to.to_string(),
|
||||
"--outdir".to_string(),
|
||||
output_dir,
|
||||
input,
|
||||
],
|
||||
)
|
||||
}
|
||||
"pandoc" => (
|
||||
"pandoc",
|
||||
vec![input, "-o".to_string(), output],
|
||||
),
|
||||
"calibre" => (
|
||||
"ebook-convert",
|
||||
vec![input, output],
|
||||
),
|
||||
"inkscape" => (
|
||||
"inkscape",
|
||||
vec![input, "--export-filename".to_string(), output],
|
||||
),
|
||||
"resvg" => (
|
||||
"resvg",
|
||||
vec![input, output],
|
||||
),
|
||||
"vips" => (
|
||||
"vips",
|
||||
vec!["copy".to_string(), input, output],
|
||||
),
|
||||
"libheif" => (
|
||||
"heif-convert",
|
||||
vec![input, output],
|
||||
),
|
||||
"libjxl" => {
|
||||
if _to == "jxl" {
|
||||
("cjxl", vec![input, output])
|
||||
} else {
|
||||
("djxl", vec![input, output])
|
||||
}
|
||||
}
|
||||
"potrace" => (
|
||||
"potrace",
|
||||
vec!["-s".to_string(), "-o".to_string(), output, input],
|
||||
),
|
||||
"vtracer" => (
|
||||
"vtracer",
|
||||
vec!["--input".to_string(), input, "--output".to_string(), output],
|
||||
),
|
||||
"dasel" => (
|
||||
"dasel",
|
||||
vec![
|
||||
"-f".to_string(),
|
||||
input,
|
||||
"-w".to_string(),
|
||||
_to.to_string(),
|
||||
"-o".to_string(),
|
||||
output,
|
||||
],
|
||||
),
|
||||
"assimp" => (
|
||||
"assimp",
|
||||
vec!["export".to_string(), input, output],
|
||||
),
|
||||
"xelatex" => {
|
||||
let output_dir = output_path.parent().unwrap().to_string_lossy().to_string();
|
||||
(
|
||||
"xelatex",
|
||||
vec![
|
||||
"-output-directory".to_string(),
|
||||
output_dir,
|
||||
input,
|
||||
],
|
||||
)
|
||||
}
|
||||
"dvisvgm" => (
|
||||
"dvisvgm",
|
||||
vec!["--no-fonts".to_string(), "-o".to_string(), output, input],
|
||||
),
|
||||
"msgconvert" => (
|
||||
"msgconvert",
|
||||
vec!["--outfile".to_string(), output, input],
|
||||
),
|
||||
_ => {
|
||||
return Err(format!("Unknown engine: {}", engine));
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!("Running converter: {} {:?}", program, args);
|
||||
|
||||
let output_result = Command::new(program)
|
||||
.args(&args)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to execute converter: {}", e))?;
|
||||
|
||||
if !output_result.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output_result.stderr);
|
||||
return Err(format!("Conversion failed: {}", stderr));
|
||||
}
|
||||
|
||||
// Verify output file exists
|
||||
if !output_path.exists() {
|
||||
return Err("Output file was not created".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a job by ID
|
||||
pub async fn get_job(&self, job_id: Uuid) -> ApiResult<ConversionJob> {
|
||||
let jobs = self.jobs.read().await;
|
||||
jobs.get(&job_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| ApiError::JobNotFound(job_id.to_string()))
|
||||
}
|
||||
|
||||
/// Get all jobs for a user
|
||||
pub async fn get_user_jobs(&self, user_id: &str) -> Vec<ConversionJob> {
|
||||
let jobs = self.jobs.read().await;
|
||||
jobs.values()
|
||||
.filter(|job| job.user_id == user_id)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Delete a job
|
||||
pub async fn delete_job(&self, job_id: Uuid, user_id: &str) -> ApiResult<()> {
|
||||
let job = self.get_job(job_id).await?;
|
||||
|
||||
// Verify ownership
|
||||
if job.user_id != user_id {
|
||||
return Err(ApiError::Unauthorized("Not authorized to delete this job".into()));
|
||||
}
|
||||
|
||||
// Remove job data
|
||||
let upload_dir = self.get_upload_path(&job_id);
|
||||
let output_dir = self.get_output_path(&job_id);
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&upload_dir).await;
|
||||
let _ = tokio::fs::remove_dir_all(&output_dir).await;
|
||||
|
||||
// Remove from storage
|
||||
let mut jobs = self.jobs.write().await;
|
||||
jobs.remove(&job_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the output file path for download
|
||||
pub async fn get_output_file(&self, job_id: Uuid, user_id: &str) -> ApiResult<PathBuf> {
|
||||
let job = self.get_job(job_id).await?;
|
||||
|
||||
// Verify ownership
|
||||
if job.user_id != user_id {
|
||||
return Err(ApiError::Unauthorized("Not authorized to access this job".into()));
|
||||
}
|
||||
|
||||
// Check job is completed
|
||||
if job.status != JobStatus::Completed {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"Job is not completed. Current status: {}",
|
||||
job.status
|
||||
)));
|
||||
}
|
||||
|
||||
let output_filename = job
|
||||
.output_filename
|
||||
.ok_or_else(|| ApiError::InternalError("Output filename not set".into()))?;
|
||||
|
||||
let output_path = self.get_output_path(&job_id).join(output_filename);
|
||||
|
||||
if !output_path.exists() {
|
||||
return Err(ApiError::FileNotFound("Output file not found".into()));
|
||||
}
|
||||
|
||||
Ok(output_path)
|
||||
}
|
||||
|
||||
/// Get upload directory path for a job
|
||||
fn get_upload_path(&self, job_id: &Uuid) -> PathBuf {
|
||||
PathBuf::from(&self.config.upload_dir).join(job_id.to_string())
|
||||
}
|
||||
|
||||
/// Get output directory path for a job
|
||||
fn get_output_path(&self, job_id: &Uuid) -> PathBuf {
|
||||
PathBuf::from(&self.config.output_dir).join(job_id.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Task-safe reference to conversion service
|
||||
struct ConversionServiceTask {
|
||||
engine_registry: Arc<EngineRegistry>,
|
||||
config: Arc<Config>,
|
||||
jobs: &'static RwLock<HashMap<Uuid, ConversionJob>>,
|
||||
}
|
||||
|
||||
impl ConversionServiceTask {
|
||||
async fn execute_conversion(&self, job_id: Uuid) {
|
||||
if let Err(e) = self.execute_conversion_internal(job_id).await {
|
||||
tracing::error!("Conversion failed for job {}: {}", job_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_conversion_internal(&self, job_id: Uuid) -> Result<(), String> {
|
||||
// Update job status to processing
|
||||
let job = {
|
||||
let mut jobs = self.jobs.write().await;
|
||||
let job = jobs.get_mut(&job_id).ok_or("Job not found")?;
|
||||
job.set_processing();
|
||||
job.clone()
|
||||
};
|
||||
|
||||
let upload_dir = PathBuf::from(&self.config.upload_dir).join(job_id.to_string());
|
||||
let output_dir = PathBuf::from(&self.config.output_dir).join(job_id.to_string());
|
||||
|
||||
let input_path = upload_dir.join(&job.original_filename);
|
||||
let output_filename = format!(
|
||||
"{}.{}",
|
||||
Path::new(&job.original_filename)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("output"),
|
||||
job.target_format
|
||||
);
|
||||
let output_path = output_dir.join(&output_filename);
|
||||
|
||||
// Execute the conversion
|
||||
let result = run_converter_command(
|
||||
&job.engine,
|
||||
&input_path,
|
||||
&output_path,
|
||||
&job.source_format,
|
||||
&job.target_format,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Update job status based on result
|
||||
let mut jobs = self.jobs.write().await;
|
||||
if let Some(job) = jobs.get_mut(&job_id) {
|
||||
match result {
|
||||
Ok(_) => {
|
||||
job.set_completed(output_filename);
|
||||
}
|
||||
Err(e) => {
|
||||
job.set_failed(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the converter command
|
||||
async fn run_converter_command(
|
||||
engine: &str,
|
||||
input_path: &Path,
|
||||
output_path: &Path,
|
||||
_from: &str,
|
||||
to: &str,
|
||||
) -> Result<(), String> {
|
||||
let input = input_path.to_string_lossy().to_string();
|
||||
let output = output_path.to_string_lossy().to_string();
|
||||
|
||||
let (program, args) = match engine {
|
||||
"ffmpeg" => (
|
||||
"ffmpeg",
|
||||
vec!["-i".to_string(), input, "-y".to_string(), output],
|
||||
),
|
||||
"imagemagick" => (
|
||||
"magick",
|
||||
vec!["convert".to_string(), input, output],
|
||||
),
|
||||
"graphicsmagick" => (
|
||||
"gm",
|
||||
vec!["convert".to_string(), input, output],
|
||||
),
|
||||
"libreoffice" => {
|
||||
let output_dir = output_path.parent().unwrap().to_string_lossy().to_string();
|
||||
(
|
||||
"libreoffice",
|
||||
vec![
|
||||
"--headless".to_string(),
|
||||
"--convert-to".to_string(),
|
||||
to.to_string(),
|
||||
"--outdir".to_string(),
|
||||
output_dir,
|
||||
input,
|
||||
],
|
||||
)
|
||||
}
|
||||
"pandoc" => (
|
||||
"pandoc",
|
||||
vec![input, "-o".to_string(), output],
|
||||
),
|
||||
"calibre" => (
|
||||
"ebook-convert",
|
||||
vec![input, output],
|
||||
),
|
||||
"inkscape" => (
|
||||
"inkscape",
|
||||
vec![input, "--export-filename".to_string(), output],
|
||||
),
|
||||
"resvg" => (
|
||||
"resvg",
|
||||
vec![input, output],
|
||||
),
|
||||
"vips" => (
|
||||
"vips",
|
||||
vec!["copy".to_string(), input, output],
|
||||
),
|
||||
"libheif" => (
|
||||
"heif-convert",
|
||||
vec![input, output],
|
||||
),
|
||||
"libjxl" => {
|
||||
if to == "jxl" {
|
||||
("cjxl", vec![input, output])
|
||||
} else {
|
||||
("djxl", vec![input, output])
|
||||
}
|
||||
}
|
||||
"potrace" => (
|
||||
"potrace",
|
||||
vec!["-s".to_string(), "-o".to_string(), output, input],
|
||||
),
|
||||
"vtracer" => (
|
||||
"vtracer",
|
||||
vec!["--input".to_string(), input, "--output".to_string(), output],
|
||||
),
|
||||
"dasel" => (
|
||||
"dasel",
|
||||
vec![
|
||||
"-f".to_string(),
|
||||
input,
|
||||
"-w".to_string(),
|
||||
to.to_string(),
|
||||
"-o".to_string(),
|
||||
output,
|
||||
],
|
||||
),
|
||||
"assimp" => (
|
||||
"assimp",
|
||||
vec!["export".to_string(), input, output],
|
||||
),
|
||||
"xelatex" => {
|
||||
let output_dir = output_path.parent().unwrap().to_string_lossy().to_string();
|
||||
(
|
||||
"xelatex",
|
||||
vec![
|
||||
"-output-directory".to_string(),
|
||||
output_dir,
|
||||
input,
|
||||
],
|
||||
)
|
||||
}
|
||||
"dvisvgm" => (
|
||||
"dvisvgm",
|
||||
vec!["--no-fonts".to_string(), "-o".to_string(), output, input],
|
||||
),
|
||||
"msgconvert" => (
|
||||
"msgconvert",
|
||||
vec!["--outfile".to_string(), output, input],
|
||||
),
|
||||
_ => {
|
||||
return Err(format!("Unknown engine: {}", engine));
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!("Running converter: {} {:?}", program, args);
|
||||
|
||||
let output_result = Command::new(program)
|
||||
.args(&args)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to execute converter: {}", e))?;
|
||||
|
||||
if !output_result.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output_result.stderr);
|
||||
return Err(format!("Conversion failed: {}", stderr));
|
||||
}
|
||||
|
||||
// Verify output file exists
|
||||
if !output_path.exists() {
|
||||
return Err("Output file was not created".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_job_invalid_format() {
|
||||
let config = Arc::new(Config::test_config());
|
||||
let registry = Arc::new(EngineRegistry::new());
|
||||
let service = ConversionService::new(registry, config);
|
||||
|
||||
let result = service
|
||||
.create_job(
|
||||
"user1".into(),
|
||||
"test".into(), // No extension
|
||||
"ffmpeg".into(),
|
||||
"mp4".into(),
|
||||
None,
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(ApiError::InvalidFile(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_job_unsupported_conversion() {
|
||||
let config = Arc::new(Config::test_config());
|
||||
let registry = Arc::new(EngineRegistry::new());
|
||||
let service = ConversionService::new(registry, config);
|
||||
|
||||
let result = service
|
||||
.create_job(
|
||||
"user1".into(),
|
||||
"test.pdf".into(),
|
||||
"ffmpeg".into(), // FFmpeg doesn't support PDF
|
||||
"mp4".into(),
|
||||
None,
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(ApiError::UnsupportedConversion { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_job_not_found() {
|
||||
let config = Arc::new(Config::test_config());
|
||||
let registry = Arc::new(EngineRegistry::new());
|
||||
let service = ConversionService::new(registry, config);
|
||||
|
||||
let result = service.get_job(Uuid::new_v4()).await;
|
||||
assert!(matches!(result, Err(ApiError::JobNotFound(_))));
|
||||
}
|
||||
}
|
||||
566
api-server/src/engine.rs
Normal file
566
api-server/src/engine.rs
Normal file
|
|
@ -0,0 +1,566 @@
|
|||
//! Engine Registry module
|
||||
//!
|
||||
//! Manages conversion engines and their capabilities.
|
||||
//! Each engine registers its supported input/output formats.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::error::{ApiError, ConversionSuggestion};
|
||||
use crate::models::EngineInfo;
|
||||
|
||||
/// Represents a conversion engine's capabilities
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Engine {
|
||||
/// Unique engine identifier
|
||||
pub id: String,
|
||||
/// Human-readable name
|
||||
pub name: String,
|
||||
/// Description
|
||||
pub description: String,
|
||||
/// Map of input format to supported output formats
|
||||
pub conversions: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
/// Create a new engine
|
||||
pub fn new(id: &str, name: &str, description: &str) -> Self {
|
||||
Self {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
conversions: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a supported conversion
|
||||
pub fn add_conversion(mut self, from: &str, to_formats: Vec<&str>) -> Self {
|
||||
let from = from.to_lowercase();
|
||||
let to: Vec<String> = to_formats.iter().map(|s| s.to_lowercase()).collect();
|
||||
self.conversions.insert(from, to);
|
||||
self
|
||||
}
|
||||
|
||||
/// Check if this engine supports a specific conversion
|
||||
pub fn supports_conversion(&self, from: &str, to: &str) -> bool {
|
||||
let from = from.to_lowercase();
|
||||
let to = to.to_lowercase();
|
||||
|
||||
self.conversions
|
||||
.get(&from)
|
||||
.map(|outputs| outputs.contains(&to))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Get all supported input formats
|
||||
pub fn input_formats(&self) -> Vec<String> {
|
||||
self.conversions.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Get all supported output formats
|
||||
pub fn output_formats(&self) -> Vec<String> {
|
||||
let mut outputs: Vec<String> = self.conversions
|
||||
.values()
|
||||
.flatten()
|
||||
.cloned()
|
||||
.collect();
|
||||
outputs.sort();
|
||||
outputs.dedup();
|
||||
outputs
|
||||
}
|
||||
|
||||
/// Get supported output formats for a given input format
|
||||
pub fn output_formats_for(&self, from: &str) -> Vec<String> {
|
||||
let from = from.to_lowercase();
|
||||
self.conversions
|
||||
.get(&from)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Convert to EngineInfo for API response
|
||||
pub fn to_info(&self) -> EngineInfo {
|
||||
EngineInfo {
|
||||
id: self.id.clone(),
|
||||
name: self.name.clone(),
|
||||
description: self.description.clone(),
|
||||
supported_input_formats: self.input_formats(),
|
||||
supported_output_formats: self.output_formats(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Registry of all available conversion engines
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EngineRegistry {
|
||||
engines: HashMap<String, Engine>,
|
||||
}
|
||||
|
||||
impl EngineRegistry {
|
||||
/// Create a new engine registry with default engines
|
||||
pub fn new() -> Self {
|
||||
let mut registry = Self {
|
||||
engines: HashMap::new(),
|
||||
};
|
||||
|
||||
// Register all available engines based on ConvertX converters
|
||||
registry.register_default_engines();
|
||||
|
||||
registry
|
||||
}
|
||||
|
||||
/// Register default engines based on ConvertX converters
|
||||
fn register_default_engines(&mut self) {
|
||||
// FFmpeg - Audio/Video conversion
|
||||
let ffmpeg = Engine::new(
|
||||
"ffmpeg",
|
||||
"FFmpeg",
|
||||
"Audio and video conversion using FFmpeg"
|
||||
)
|
||||
.add_conversion("mp4", vec!["webm", "avi", "mkv", "mov", "mp3", "wav", "flac", "ogg", "gif"])
|
||||
.add_conversion("webm", vec!["mp4", "avi", "mkv", "mov", "mp3", "wav", "flac", "ogg", "gif"])
|
||||
.add_conversion("avi", vec!["mp4", "webm", "mkv", "mov", "mp3", "wav", "flac", "ogg", "gif"])
|
||||
.add_conversion("mkv", vec!["mp4", "webm", "avi", "mov", "mp3", "wav", "flac", "ogg", "gif"])
|
||||
.add_conversion("mov", vec!["mp4", "webm", "avi", "mkv", "mp3", "wav", "flac", "ogg", "gif"])
|
||||
.add_conversion("mp3", vec!["wav", "flac", "ogg", "m4a", "aac"])
|
||||
.add_conversion("wav", vec!["mp3", "flac", "ogg", "m4a", "aac"])
|
||||
.add_conversion("flac", vec!["mp3", "wav", "ogg", "m4a", "aac"])
|
||||
.add_conversion("ogg", vec!["mp3", "wav", "flac", "m4a", "aac"])
|
||||
.add_conversion("m4a", vec!["mp3", "wav", "flac", "ogg", "aac"])
|
||||
.add_conversion("gif", vec!["mp4", "webm"]);
|
||||
self.register(ffmpeg);
|
||||
|
||||
// ImageMagick - Image conversion
|
||||
let imagemagick = Engine::new(
|
||||
"imagemagick",
|
||||
"ImageMagick",
|
||||
"Image format conversion using ImageMagick"
|
||||
)
|
||||
.add_conversion("png", vec!["jpg", "jpeg", "gif", "bmp", "webp", "tiff", "ico", "pdf"])
|
||||
.add_conversion("jpg", vec!["png", "gif", "bmp", "webp", "tiff", "ico", "pdf"])
|
||||
.add_conversion("jpeg", vec!["png", "gif", "bmp", "webp", "tiff", "ico", "pdf"])
|
||||
.add_conversion("gif", vec!["png", "jpg", "jpeg", "bmp", "webp", "tiff"])
|
||||
.add_conversion("bmp", vec!["png", "jpg", "jpeg", "gif", "webp", "tiff"])
|
||||
.add_conversion("webp", vec!["png", "jpg", "jpeg", "gif", "bmp", "tiff"])
|
||||
.add_conversion("tiff", vec!["png", "jpg", "jpeg", "gif", "bmp", "webp", "pdf"])
|
||||
.add_conversion("svg", vec!["png", "jpg", "jpeg", "pdf"]);
|
||||
self.register(imagemagick);
|
||||
|
||||
// GraphicsMagick - Image conversion (alternative)
|
||||
let graphicsmagick = Engine::new(
|
||||
"graphicsmagick",
|
||||
"GraphicsMagick",
|
||||
"Image format conversion using GraphicsMagick"
|
||||
)
|
||||
.add_conversion("png", vec!["jpg", "jpeg", "gif", "bmp", "tiff"])
|
||||
.add_conversion("jpg", vec!["png", "gif", "bmp", "tiff"])
|
||||
.add_conversion("jpeg", vec!["png", "gif", "bmp", "tiff"])
|
||||
.add_conversion("gif", vec!["png", "jpg", "jpeg", "bmp", "tiff"])
|
||||
.add_conversion("bmp", vec!["png", "jpg", "jpeg", "gif", "tiff"])
|
||||
.add_conversion("tiff", vec!["png", "jpg", "jpeg", "gif", "bmp"]);
|
||||
self.register(graphicsmagick);
|
||||
|
||||
// LibreOffice - Document conversion
|
||||
let libreoffice = Engine::new(
|
||||
"libreoffice",
|
||||
"LibreOffice",
|
||||
"Document conversion using LibreOffice"
|
||||
)
|
||||
.add_conversion("docx", vec!["pdf", "odt", "html", "txt", "rtf"])
|
||||
.add_conversion("doc", vec!["pdf", "odt", "docx", "html", "txt", "rtf"])
|
||||
.add_conversion("odt", vec!["pdf", "docx", "html", "txt", "rtf"])
|
||||
.add_conversion("xlsx", vec!["pdf", "ods", "csv", "html"])
|
||||
.add_conversion("xls", vec!["pdf", "ods", "xlsx", "csv", "html"])
|
||||
.add_conversion("ods", vec!["pdf", "xlsx", "csv", "html"])
|
||||
.add_conversion("pptx", vec!["pdf", "odp", "html"])
|
||||
.add_conversion("ppt", vec!["pdf", "odp", "pptx", "html"])
|
||||
.add_conversion("odp", vec!["pdf", "pptx", "html"])
|
||||
.add_conversion("rtf", vec!["pdf", "docx", "odt", "html", "txt"]);
|
||||
self.register(libreoffice);
|
||||
|
||||
// Pandoc - Document/Markup conversion
|
||||
let pandoc = Engine::new(
|
||||
"pandoc",
|
||||
"Pandoc",
|
||||
"Universal document converter"
|
||||
)
|
||||
.add_conversion("md", vec!["html", "pdf", "docx", "epub", "latex", "rst"])
|
||||
.add_conversion("markdown", vec!["html", "pdf", "docx", "epub", "latex", "rst"])
|
||||
.add_conversion("html", vec!["md", "markdown", "pdf", "docx", "epub", "latex"])
|
||||
.add_conversion("rst", vec!["html", "md", "markdown", "pdf", "docx", "latex"])
|
||||
.add_conversion("latex", vec!["html", "pdf", "docx"])
|
||||
.add_conversion("tex", vec!["html", "pdf", "docx"])
|
||||
.add_conversion("epub", vec!["html", "pdf", "docx", "md"])
|
||||
.add_conversion("docx", vec!["md", "markdown", "html", "pdf", "epub", "rst"]);
|
||||
self.register(pandoc);
|
||||
|
||||
// Calibre - eBook conversion
|
||||
let calibre = Engine::new(
|
||||
"calibre",
|
||||
"Calibre",
|
||||
"eBook format conversion using Calibre"
|
||||
)
|
||||
.add_conversion("epub", vec!["mobi", "azw3", "pdf", "html", "txt"])
|
||||
.add_conversion("mobi", vec!["epub", "azw3", "pdf", "html", "txt"])
|
||||
.add_conversion("azw3", vec!["epub", "mobi", "pdf", "html", "txt"])
|
||||
.add_conversion("pdf", vec!["epub", "mobi", "html", "txt"]);
|
||||
self.register(calibre);
|
||||
|
||||
// Inkscape - Vector graphics conversion
|
||||
let inkscape = Engine::new(
|
||||
"inkscape",
|
||||
"Inkscape",
|
||||
"Vector graphics conversion using Inkscape"
|
||||
)
|
||||
.add_conversion("svg", vec!["png", "pdf", "eps", "emf", "wmf"])
|
||||
.add_conversion("eps", vec!["svg", "png", "pdf"])
|
||||
.add_conversion("emf", vec!["svg", "png", "pdf"])
|
||||
.add_conversion("wmf", vec!["svg", "png", "pdf"]);
|
||||
self.register(inkscape);
|
||||
|
||||
// resvg - SVG rendering
|
||||
let resvg = Engine::new(
|
||||
"resvg",
|
||||
"resvg",
|
||||
"High-quality SVG rendering"
|
||||
)
|
||||
.add_conversion("svg", vec!["png"]);
|
||||
self.register(resvg);
|
||||
|
||||
// VIPS - High-performance image processing
|
||||
let vips = Engine::new(
|
||||
"vips",
|
||||
"libvips",
|
||||
"High-performance image processing with libvips"
|
||||
)
|
||||
.add_conversion("png", vec!["jpg", "jpeg", "webp", "tiff", "heif", "avif"])
|
||||
.add_conversion("jpg", vec!["png", "webp", "tiff", "heif", "avif"])
|
||||
.add_conversion("jpeg", vec!["png", "webp", "tiff", "heif", "avif"])
|
||||
.add_conversion("webp", vec!["png", "jpg", "jpeg", "tiff", "heif", "avif"])
|
||||
.add_conversion("tiff", vec!["png", "jpg", "jpeg", "webp", "heif", "avif"])
|
||||
.add_conversion("heif", vec!["png", "jpg", "jpeg", "webp", "tiff"])
|
||||
.add_conversion("avif", vec!["png", "jpg", "jpeg", "webp", "tiff"]);
|
||||
self.register(vips);
|
||||
|
||||
// libheif - HEIF/HEIC conversion
|
||||
let libheif = Engine::new(
|
||||
"libheif",
|
||||
"libheif",
|
||||
"HEIF/HEIC image format conversion"
|
||||
)
|
||||
.add_conversion("heic", vec!["jpg", "jpeg", "png"])
|
||||
.add_conversion("heif", vec!["jpg", "jpeg", "png"]);
|
||||
self.register(libheif);
|
||||
|
||||
// libjxl - JPEG XL conversion
|
||||
let libjxl = Engine::new(
|
||||
"libjxl",
|
||||
"libjxl",
|
||||
"JPEG XL image format conversion"
|
||||
)
|
||||
.add_conversion("jxl", vec!["jpg", "jpeg", "png"])
|
||||
.add_conversion("jpg", vec!["jxl"])
|
||||
.add_conversion("jpeg", vec!["jxl"])
|
||||
.add_conversion("png", vec!["jxl"]);
|
||||
self.register(libjxl);
|
||||
|
||||
// Potrace - Bitmap to vector tracing
|
||||
let potrace = Engine::new(
|
||||
"potrace",
|
||||
"Potrace",
|
||||
"Bitmap to vector graphics tracing"
|
||||
)
|
||||
.add_conversion("bmp", vec!["svg", "eps", "pdf"])
|
||||
.add_conversion("png", vec!["svg", "eps", "pdf"])
|
||||
.add_conversion("pnm", vec!["svg", "eps", "pdf"]);
|
||||
self.register(potrace);
|
||||
|
||||
// VTracer - Advanced bitmap tracing
|
||||
let vtracer = Engine::new(
|
||||
"vtracer",
|
||||
"VTracer",
|
||||
"Advanced raster to vector graphics conversion"
|
||||
)
|
||||
.add_conversion("png", vec!["svg"])
|
||||
.add_conversion("jpg", vec!["svg"])
|
||||
.add_conversion("jpeg", vec!["svg"])
|
||||
.add_conversion("bmp", vec!["svg"]);
|
||||
self.register(vtracer);
|
||||
|
||||
// Dasel - Data format conversion
|
||||
let dasel = Engine::new(
|
||||
"dasel",
|
||||
"Dasel",
|
||||
"Data format conversion (JSON, YAML, TOML, XML)"
|
||||
)
|
||||
.add_conversion("json", vec!["yaml", "yml", "toml", "xml", "csv"])
|
||||
.add_conversion("yaml", vec!["json", "toml", "xml", "csv"])
|
||||
.add_conversion("yml", vec!["json", "toml", "xml", "csv"])
|
||||
.add_conversion("toml", vec!["json", "yaml", "yml", "xml"])
|
||||
.add_conversion("xml", vec!["json", "yaml", "yml"]);
|
||||
self.register(dasel);
|
||||
|
||||
// Assimp - 3D model conversion
|
||||
let assimp = Engine::new(
|
||||
"assimp",
|
||||
"Assimp",
|
||||
"3D model format conversion"
|
||||
)
|
||||
.add_conversion("obj", vec!["fbx", "gltf", "glb", "stl", "ply", "3ds"])
|
||||
.add_conversion("fbx", vec!["obj", "gltf", "glb", "stl", "ply"])
|
||||
.add_conversion("gltf", vec!["obj", "fbx", "glb", "stl"])
|
||||
.add_conversion("glb", vec!["obj", "fbx", "gltf", "stl"])
|
||||
.add_conversion("stl", vec!["obj", "fbx", "gltf", "glb", "ply"])
|
||||
.add_conversion("3ds", vec!["obj", "fbx", "gltf", "glb"]);
|
||||
self.register(assimp);
|
||||
|
||||
// XeLaTeX - LaTeX to PDF
|
||||
let xelatex = Engine::new(
|
||||
"xelatex",
|
||||
"XeLaTeX",
|
||||
"LaTeX document compilation"
|
||||
)
|
||||
.add_conversion("tex", vec!["pdf"])
|
||||
.add_conversion("latex", vec!["pdf"]);
|
||||
self.register(xelatex);
|
||||
|
||||
// dvisvgm - DVI to SVG
|
||||
let dvisvgm = Engine::new(
|
||||
"dvisvgm",
|
||||
"dvisvgm",
|
||||
"DVI to SVG conversion"
|
||||
)
|
||||
.add_conversion("dvi", vec!["svg"]);
|
||||
self.register(dvisvgm);
|
||||
|
||||
// msgconvert - Email conversion
|
||||
let msgconvert = Engine::new(
|
||||
"msgconvert",
|
||||
"msgconvert",
|
||||
"Outlook MSG to EML conversion"
|
||||
)
|
||||
.add_conversion("msg", vec!["eml"]);
|
||||
self.register(msgconvert);
|
||||
|
||||
// VCF converter
|
||||
let vcf = Engine::new(
|
||||
"vcf",
|
||||
"VCF Converter",
|
||||
"vCard format conversion"
|
||||
)
|
||||
.add_conversion("vcf", vec!["csv", "json"]);
|
||||
self.register(vcf);
|
||||
|
||||
// MarkItDown - Document to Markdown
|
||||
let markitdown = Engine::new(
|
||||
"markitdown",
|
||||
"MarkItDown",
|
||||
"Convert various documents to Markdown"
|
||||
)
|
||||
.add_conversion("pdf", vec!["md", "markdown"])
|
||||
.add_conversion("docx", vec!["md", "markdown"])
|
||||
.add_conversion("pptx", vec!["md", "markdown"])
|
||||
.add_conversion("xlsx", vec!["md", "markdown"])
|
||||
.add_conversion("html", vec!["md", "markdown"]);
|
||||
self.register(markitdown);
|
||||
}
|
||||
|
||||
/// Register an engine
|
||||
pub fn register(&mut self, engine: Engine) {
|
||||
self.engines.insert(engine.id.clone(), engine);
|
||||
}
|
||||
|
||||
/// Get an engine by ID
|
||||
pub fn get(&self, engine_id: &str) -> Option<&Engine> {
|
||||
self.engines.get(engine_id)
|
||||
}
|
||||
|
||||
/// List all engines
|
||||
pub fn list(&self) -> Vec<&Engine> {
|
||||
self.engines.values().collect()
|
||||
}
|
||||
|
||||
/// Get all engine info for API response
|
||||
pub fn list_info(&self) -> Vec<EngineInfo> {
|
||||
let mut engines: Vec<EngineInfo> = self.engines.values().map(|e| e.to_info()).collect();
|
||||
engines.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
engines
|
||||
}
|
||||
|
||||
/// Validate a conversion request
|
||||
/// Returns Ok if valid, or an error with suggestions if not
|
||||
pub fn validate_conversion(
|
||||
&self,
|
||||
engine_id: &str,
|
||||
from: &str,
|
||||
to: &str,
|
||||
) -> Result<(), ApiError> {
|
||||
// Check if engine exists
|
||||
let engine = self.engines.get(engine_id).ok_or_else(|| {
|
||||
ApiError::EngineNotFound(engine_id.to_string())
|
||||
})?;
|
||||
|
||||
// Check if conversion is supported
|
||||
if engine.supports_conversion(from, to) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Generate suggestions
|
||||
let suggestions = self.find_suggestions(from, to);
|
||||
|
||||
Err(ApiError::UnsupportedConversion {
|
||||
engine: engine_id.to_string(),
|
||||
from: from.to_string(),
|
||||
to: to.to_string(),
|
||||
suggestions,
|
||||
})
|
||||
}
|
||||
|
||||
/// Find alternative engines/formats that can handle a conversion
|
||||
pub fn find_suggestions(&self, from: &str, to: &str) -> Vec<ConversionSuggestion> {
|
||||
let from = from.to_lowercase();
|
||||
let to = to.to_lowercase();
|
||||
let mut suggestions = Vec::new();
|
||||
|
||||
// First, find engines that support this exact conversion
|
||||
for engine in self.engines.values() {
|
||||
if engine.supports_conversion(&from, &to) {
|
||||
suggestions.push(ConversionSuggestion {
|
||||
engine: engine.id.clone(),
|
||||
from: from.clone(),
|
||||
to: to.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If no exact matches, find engines that support the input format
|
||||
if suggestions.is_empty() {
|
||||
for engine in self.engines.values() {
|
||||
if let Some(outputs) = engine.conversions.get(&from) {
|
||||
for output in outputs {
|
||||
suggestions.push(ConversionSuggestion {
|
||||
engine: engine.id.clone(),
|
||||
from: from.clone(),
|
||||
to: output.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Limit suggestions
|
||||
suggestions.truncate(10);
|
||||
suggestions
|
||||
}
|
||||
|
||||
/// Check if any engine supports a given input format
|
||||
pub fn has_input_format(&self, format: &str) -> bool {
|
||||
let format = format.to_lowercase();
|
||||
self.engines.values().any(|e| e.conversions.contains_key(&format))
|
||||
}
|
||||
|
||||
/// Get all engines that support a given input format
|
||||
pub fn engines_for_input(&self, format: &str) -> Vec<&Engine> {
|
||||
let format = format.to_lowercase();
|
||||
self.engines
|
||||
.values()
|
||||
.filter(|e| e.conversions.contains_key(&format))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EngineRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_engine_creation() {
|
||||
let engine = Engine::new("test", "Test Engine", "A test engine")
|
||||
.add_conversion("png", vec!["jpg", "gif"])
|
||||
.add_conversion("jpg", vec!["png"]);
|
||||
|
||||
assert_eq!(engine.id, "test");
|
||||
assert!(engine.supports_conversion("png", "jpg"));
|
||||
assert!(engine.supports_conversion("png", "gif"));
|
||||
assert!(engine.supports_conversion("jpg", "png"));
|
||||
assert!(!engine.supports_conversion("gif", "png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_engine_case_insensitive() {
|
||||
let engine = Engine::new("test", "Test", "Test")
|
||||
.add_conversion("PNG", vec!["JPG"]);
|
||||
|
||||
assert!(engine.supports_conversion("png", "jpg"));
|
||||
assert!(engine.supports_conversion("PNG", "JPG"));
|
||||
assert!(engine.supports_conversion("Png", "Jpg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_default_engines() {
|
||||
let registry = EngineRegistry::new();
|
||||
|
||||
assert!(registry.get("ffmpeg").is_some());
|
||||
assert!(registry.get("imagemagick").is_some());
|
||||
assert!(registry.get("libreoffice").is_some());
|
||||
assert!(registry.get("pandoc").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_validation_success() {
|
||||
let registry = EngineRegistry::new();
|
||||
|
||||
assert!(registry.validate_conversion("ffmpeg", "mp4", "webm").is_ok());
|
||||
assert!(registry.validate_conversion("imagemagick", "png", "jpg").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_validation_engine_not_found() {
|
||||
let registry = EngineRegistry::new();
|
||||
|
||||
let result = registry.validate_conversion("nonexistent", "png", "jpg");
|
||||
assert!(matches!(result, Err(ApiError::EngineNotFound(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_validation_unsupported_with_suggestions() {
|
||||
let registry = EngineRegistry::new();
|
||||
|
||||
// FFmpeg doesn't support png to jpg
|
||||
let result = registry.validate_conversion("ffmpeg", "png", "jpg");
|
||||
|
||||
if let Err(ApiError::UnsupportedConversion { suggestions, .. }) = result {
|
||||
// Should suggest imagemagick or vips for png to jpg
|
||||
assert!(!suggestions.is_empty());
|
||||
assert!(suggestions.iter().any(|s| s.engine == "imagemagick" || s.engine == "vips"));
|
||||
} else {
|
||||
panic!("Expected UnsupportedConversion error");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_suggestions() {
|
||||
let registry = EngineRegistry::new();
|
||||
|
||||
let suggestions = registry.find_suggestions("png", "jpg");
|
||||
assert!(!suggestions.is_empty());
|
||||
|
||||
// Should include engines that support png to jpg
|
||||
let has_imagemagick = suggestions.iter().any(|s| s.engine == "imagemagick");
|
||||
assert!(has_imagemagick);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_info() {
|
||||
let registry = EngineRegistry::new();
|
||||
let info = registry.list_info();
|
||||
|
||||
assert!(!info.is_empty());
|
||||
assert!(info.iter().any(|e| e.id == "ffmpeg"));
|
||||
}
|
||||
}
|
||||
202
api-server/src/error.rs
Normal file
202
api-server/src/error.rs
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
//! Error handling module with structured error responses and suggestions
|
||||
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Suggestion for alternative conversion options
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConversionSuggestion {
|
||||
/// Suggested engine to use
|
||||
pub engine: String,
|
||||
/// Source format
|
||||
pub from: String,
|
||||
/// Target format
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
/// Structured error response
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub error: ErrorDetail,
|
||||
}
|
||||
|
||||
/// Error detail with code, message, and optional suggestions
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ErrorDetail {
|
||||
/// Error code for programmatic handling
|
||||
pub code: String,
|
||||
/// Human-readable error message
|
||||
pub message: String,
|
||||
/// Optional suggestions for resolving the error
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub suggestions: Option<Vec<ConversionSuggestion>>,
|
||||
}
|
||||
|
||||
/// API Error types
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ApiError {
|
||||
#[error("Unauthorized: {0}")]
|
||||
Unauthorized(String),
|
||||
|
||||
#[error("Invalid token: {0}")]
|
||||
InvalidToken(String),
|
||||
|
||||
#[error("Token expired")]
|
||||
TokenExpired,
|
||||
|
||||
#[error("Missing authorization header")]
|
||||
MissingAuthHeader,
|
||||
|
||||
#[error("Invalid file: {0}")]
|
||||
InvalidFile(String),
|
||||
|
||||
#[error("Engine not found: {0}")]
|
||||
EngineNotFound(String),
|
||||
|
||||
#[error("Unsupported conversion from {from} to {to} using engine {engine}")]
|
||||
UnsupportedConversion {
|
||||
engine: String,
|
||||
from: String,
|
||||
to: String,
|
||||
suggestions: Vec<ConversionSuggestion>,
|
||||
},
|
||||
|
||||
#[error("Conversion failed: {0}")]
|
||||
ConversionFailed(String),
|
||||
|
||||
#[error("Job not found: {0}")]
|
||||
JobNotFound(String),
|
||||
|
||||
#[error("File not found: {0}")]
|
||||
FileNotFound(String),
|
||||
|
||||
#[error("Invalid request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("Internal server error: {0}")]
|
||||
InternalError(String),
|
||||
|
||||
#[error("File too large: max size is {max_size} bytes")]
|
||||
FileTooLarge { max_size: usize },
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
/// Get the error code string
|
||||
pub fn code(&self) -> &'static str {
|
||||
match self {
|
||||
ApiError::Unauthorized(_) => "UNAUTHORIZED",
|
||||
ApiError::InvalidToken(_) => "INVALID_TOKEN",
|
||||
ApiError::TokenExpired => "TOKEN_EXPIRED",
|
||||
ApiError::MissingAuthHeader => "MISSING_AUTH_HEADER",
|
||||
ApiError::InvalidFile(_) => "INVALID_FILE",
|
||||
ApiError::EngineNotFound(_) => "ENGINE_NOT_FOUND",
|
||||
ApiError::UnsupportedConversion { .. } => "UNSUPPORTED_CONVERSION",
|
||||
ApiError::ConversionFailed(_) => "CONVERSION_FAILED",
|
||||
ApiError::JobNotFound(_) => "JOB_NOT_FOUND",
|
||||
ApiError::FileNotFound(_) => "FILE_NOT_FOUND",
|
||||
ApiError::BadRequest(_) => "BAD_REQUEST",
|
||||
ApiError::InternalError(_) => "INTERNAL_ERROR",
|
||||
ApiError::FileTooLarge { .. } => "FILE_TOO_LARGE",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get HTTP status code
|
||||
pub fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
ApiError::Unauthorized(_)
|
||||
| ApiError::InvalidToken(_)
|
||||
| ApiError::TokenExpired
|
||||
| ApiError::MissingAuthHeader => StatusCode::UNAUTHORIZED,
|
||||
|
||||
ApiError::InvalidFile(_)
|
||||
| ApiError::BadRequest(_)
|
||||
| ApiError::FileTooLarge { .. } => StatusCode::BAD_REQUEST,
|
||||
|
||||
ApiError::EngineNotFound(_)
|
||||
| ApiError::JobNotFound(_)
|
||||
| ApiError::FileNotFound(_) => StatusCode::NOT_FOUND,
|
||||
|
||||
ApiError::UnsupportedConversion { .. } => StatusCode::UNPROCESSABLE_ENTITY,
|
||||
|
||||
ApiError::ConversionFailed(_)
|
||||
| ApiError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get suggestions if available
|
||||
pub fn suggestions(&self) -> Option<Vec<ConversionSuggestion>> {
|
||||
match self {
|
||||
ApiError::UnsupportedConversion { suggestions, .. } => Some(suggestions.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to ErrorResponse
|
||||
pub fn to_error_response(&self) -> ErrorResponse {
|
||||
ErrorResponse {
|
||||
error: ErrorDetail {
|
||||
code: self.code().to_string(),
|
||||
message: self.to_string(),
|
||||
suggestions: self.suggestions(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = self.status_code();
|
||||
let body = Json(self.to_error_response());
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// Result type alias for API operations
|
||||
pub type ApiResult<T> = Result<T, ApiError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_error_codes() {
|
||||
assert_eq!(ApiError::Unauthorized("test".into()).code(), "UNAUTHORIZED");
|
||||
assert_eq!(ApiError::TokenExpired.code(), "TOKEN_EXPIRED");
|
||||
assert_eq!(
|
||||
ApiError::UnsupportedConversion {
|
||||
engine: "test".into(),
|
||||
from: "pdf".into(),
|
||||
to: "xyz".into(),
|
||||
suggestions: vec![],
|
||||
}
|
||||
.code(),
|
||||
"UNSUPPORTED_CONVERSION"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_response_serialization() {
|
||||
let error = ApiError::UnsupportedConversion {
|
||||
engine: "ffmpeg".into(),
|
||||
from: "pdf".into(),
|
||||
to: "mp4".into(),
|
||||
suggestions: vec![ConversionSuggestion {
|
||||
engine: "libreoffice".into(),
|
||||
from: "pdf".into(),
|
||||
to: "docx".into(),
|
||||
}],
|
||||
};
|
||||
|
||||
let response = error.to_error_response();
|
||||
let json = serde_json::to_string(&response).unwrap();
|
||||
|
||||
assert!(json.contains("UNSUPPORTED_CONVERSION"));
|
||||
assert!(json.contains("suggestions"));
|
||||
assert!(json.contains("libreoffice"));
|
||||
}
|
||||
}
|
||||
526
api-server/src/graphql.rs
Normal file
526
api-server/src/graphql.rs
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
//! GraphQL API module
|
||||
//!
|
||||
//! Provides GraphQL endpoints for file conversion operations.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_graphql::{
|
||||
Context, EmptySubscription, Enum, InputObject, Object, Result as GqlResult, Schema,
|
||||
SimpleObject, Upload, ID,
|
||||
};
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use async_graphql_axum::{GraphQLRequest, GraphQLResponse};
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::{JwtValidator, AuthenticatedUser, Claims};
|
||||
use crate::engine::EngineRegistry;
|
||||
use crate::conversion::ConversionService;
|
||||
use crate::config::Config;
|
||||
use crate::error::ConversionSuggestion;
|
||||
use crate::models::JobStatus;
|
||||
|
||||
/// GraphQL Schema type
|
||||
pub type ApiSchema = Schema<QueryRoot, MutationRoot, EmptySubscription>;
|
||||
|
||||
/// Create GraphQL routes
|
||||
pub fn routes() -> Router<crate::AppState> {
|
||||
Router::new()
|
||||
.route("/graphql", post(graphql_handler))
|
||||
.route("/graphql", get(graphql_playground))
|
||||
}
|
||||
|
||||
/// GraphQL handler
|
||||
async fn graphql_handler(
|
||||
State(state): State<crate::AppState>,
|
||||
headers: HeaderMap,
|
||||
req: GraphQLRequest,
|
||||
) -> GraphQLResponse {
|
||||
let schema = build_schema(
|
||||
state.engine_registry.clone(),
|
||||
state.conversion_service.clone(),
|
||||
state.config.clone(),
|
||||
);
|
||||
|
||||
// Extract JWT from headers and add to context
|
||||
let mut request = req.into_inner();
|
||||
|
||||
if let Some(auth_header) = headers.get("authorization") {
|
||||
if let Ok(auth_str) = auth_header.to_str() {
|
||||
request = request.data(AuthHeader(auth_str.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
schema.execute(request).await.into()
|
||||
}
|
||||
|
||||
/// GraphQL Playground handler
|
||||
async fn graphql_playground() -> impl axum::response::IntoResponse {
|
||||
axum::response::Html(async_graphql::http::playground_source(
|
||||
async_graphql::http::GraphQLPlaygroundConfig::new("/graphql"),
|
||||
))
|
||||
}
|
||||
|
||||
/// Build the GraphQL schema
|
||||
pub fn build_schema(
|
||||
engine_registry: Arc<EngineRegistry>,
|
||||
conversion_service: Arc<ConversionService>,
|
||||
config: Arc<Config>,
|
||||
) -> ApiSchema {
|
||||
Schema::build(QueryRoot, MutationRoot, EmptySubscription)
|
||||
.data(engine_registry)
|
||||
.data(conversion_service)
|
||||
.data(config)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Authorization header wrapper
|
||||
struct AuthHeader(String);
|
||||
|
||||
/// Validate JWT from context
|
||||
fn validate_auth(ctx: &Context<'_>) -> GqlResult<AuthenticatedUser> {
|
||||
let config = ctx.data::<Arc<Config>>()?;
|
||||
let auth_header = ctx.data::<AuthHeader>()
|
||||
.map_err(|_| async_graphql::Error::new("Missing authorization header"))?;
|
||||
|
||||
let validator = JwtValidator::new(&config.jwt_secret);
|
||||
|
||||
let token = JwtValidator::extract_token(&auth_header.0)
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
|
||||
let claims = validator.validate(token)
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
|
||||
Ok(AuthenticatedUser {
|
||||
user_id: claims.sub.clone(),
|
||||
email: claims.email.clone(),
|
||||
roles: claims.roles.clone(),
|
||||
claims,
|
||||
})
|
||||
}
|
||||
|
||||
// ============ GraphQL Types ============
|
||||
|
||||
/// Job status enum for GraphQL
|
||||
#[derive(Enum, Copy, Clone, Eq, PartialEq)]
|
||||
pub enum GqlJobStatus {
|
||||
Pending,
|
||||
Processing,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl From<JobStatus> for GqlJobStatus {
|
||||
fn from(status: JobStatus) -> Self {
|
||||
match status {
|
||||
JobStatus::Pending => GqlJobStatus::Pending,
|
||||
JobStatus::Processing => GqlJobStatus::Processing,
|
||||
JobStatus::Completed => GqlJobStatus::Completed,
|
||||
JobStatus::Failed => GqlJobStatus::Failed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Engine information
|
||||
#[derive(SimpleObject)]
|
||||
pub struct GqlEngine {
|
||||
/// Engine identifier
|
||||
pub id: ID,
|
||||
/// Human-readable name
|
||||
pub name: String,
|
||||
/// Description
|
||||
pub description: String,
|
||||
/// Supported input formats
|
||||
pub supported_input_formats: Vec<String>,
|
||||
/// Supported output formats
|
||||
pub supported_output_formats: Vec<String>,
|
||||
}
|
||||
|
||||
/// Conversion job
|
||||
#[derive(SimpleObject)]
|
||||
pub struct GqlJob {
|
||||
/// Unique job identifier
|
||||
pub id: ID,
|
||||
/// Original filename
|
||||
pub original_filename: String,
|
||||
/// Source format
|
||||
pub source_format: String,
|
||||
/// Target format
|
||||
pub target_format: String,
|
||||
/// Engine used
|
||||
pub engine: String,
|
||||
/// Current status
|
||||
pub status: GqlJobStatus,
|
||||
/// Output filename (when completed)
|
||||
pub output_filename: Option<String>,
|
||||
/// Error message (when failed)
|
||||
pub error_message: Option<String>,
|
||||
/// Download URL (when completed)
|
||||
pub download_url: Option<String>,
|
||||
/// Creation timestamp
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// Completion timestamp
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Conversion suggestion
|
||||
#[derive(SimpleObject)]
|
||||
pub struct GqlSuggestion {
|
||||
/// Suggested engine
|
||||
pub engine: String,
|
||||
/// Source format
|
||||
pub from: String,
|
||||
/// Target format
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
impl From<ConversionSuggestion> for GqlSuggestion {
|
||||
fn from(s: ConversionSuggestion) -> Self {
|
||||
Self {
|
||||
engine: s.engine,
|
||||
from: s.from,
|
||||
to: s.to,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversion error with suggestions
|
||||
#[derive(SimpleObject)]
|
||||
pub struct GqlConversionError {
|
||||
/// Error code
|
||||
pub code: String,
|
||||
/// Error message
|
||||
pub message: String,
|
||||
/// Suggestions for alternative conversions
|
||||
pub suggestions: Vec<GqlSuggestion>,
|
||||
}
|
||||
|
||||
/// Result of creating a job
|
||||
#[derive(SimpleObject)]
|
||||
pub struct CreateJobResult {
|
||||
/// Whether the operation succeeded
|
||||
pub success: bool,
|
||||
/// The created job (if successful)
|
||||
pub job: Option<GqlJob>,
|
||||
/// Error information (if failed)
|
||||
pub error: Option<GqlConversionError>,
|
||||
}
|
||||
|
||||
/// Input for creating a conversion job
|
||||
#[derive(InputObject)]
|
||||
pub struct CreateJobInput {
|
||||
/// Engine to use for conversion
|
||||
pub engine: String,
|
||||
/// Target format
|
||||
pub target_format: String,
|
||||
/// Conversion options (JSON)
|
||||
pub options: Option<String>,
|
||||
}
|
||||
|
||||
/// Health status
|
||||
#[derive(SimpleObject)]
|
||||
pub struct GqlHealth {
|
||||
pub status: String,
|
||||
pub version: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// ============ Query Root ============
|
||||
|
||||
pub struct QueryRoot;
|
||||
|
||||
#[Object]
|
||||
impl QueryRoot {
|
||||
/// Health check
|
||||
async fn health(&self) -> GqlHealth {
|
||||
GqlHealth {
|
||||
status: "healthy".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
timestamp: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all available conversion engines
|
||||
async fn engines(&self, ctx: &Context<'_>) -> GqlResult<Vec<GqlEngine>> {
|
||||
let _user = validate_auth(ctx)?;
|
||||
let registry = ctx.data::<Arc<EngineRegistry>>()?;
|
||||
|
||||
let engines: Vec<GqlEngine> = registry
|
||||
.list_info()
|
||||
.into_iter()
|
||||
.map(|e| GqlEngine {
|
||||
id: ID(e.id),
|
||||
name: e.name,
|
||||
description: e.description,
|
||||
supported_input_formats: e.supported_input_formats,
|
||||
supported_output_formats: e.supported_output_formats,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(engines)
|
||||
}
|
||||
|
||||
/// Get a specific engine
|
||||
async fn engine(&self, ctx: &Context<'_>, id: ID) -> GqlResult<Option<GqlEngine>> {
|
||||
let _user = validate_auth(ctx)?;
|
||||
let registry = ctx.data::<Arc<EngineRegistry>>()?;
|
||||
|
||||
Ok(registry.get(&id.0).map(|e| GqlEngine {
|
||||
id: ID(e.id.clone()),
|
||||
name: e.name.clone(),
|
||||
description: e.description.clone(),
|
||||
supported_input_formats: e.input_formats(),
|
||||
supported_output_formats: e.output_formats(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get all jobs for the authenticated user
|
||||
async fn jobs(&self, ctx: &Context<'_>) -> GqlResult<Vec<GqlJob>> {
|
||||
let user = validate_auth(ctx)?;
|
||||
let service = ctx.data::<Arc<ConversionService>>()?;
|
||||
|
||||
let jobs = service.get_user_jobs(&user.user_id).await;
|
||||
|
||||
Ok(jobs
|
||||
.into_iter()
|
||||
.map(|j| GqlJob {
|
||||
id: ID(j.id.to_string()),
|
||||
original_filename: j.original_filename,
|
||||
source_format: j.source_format,
|
||||
target_format: j.target_format,
|
||||
engine: j.engine,
|
||||
status: j.status.into(),
|
||||
output_filename: j.output_filename,
|
||||
error_message: j.error_message,
|
||||
download_url: if j.status == JobStatus::Completed {
|
||||
Some(format!("/api/v1/jobs/{}/download", j.id))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
created_at: j.created_at,
|
||||
completed_at: j.completed_at,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Get a specific job
|
||||
async fn job(&self, ctx: &Context<'_>, id: ID) -> GqlResult<Option<GqlJob>> {
|
||||
let user = validate_auth(ctx)?;
|
||||
let service = ctx.data::<Arc<ConversionService>>()?;
|
||||
|
||||
let job_id = Uuid::parse_str(&id.0)
|
||||
.map_err(|_| async_graphql::Error::new("Invalid job ID format"))?;
|
||||
|
||||
match service.get_job(job_id).await {
|
||||
Ok(job) => {
|
||||
if job.user_id != user.user_id {
|
||||
return Err(async_graphql::Error::new("Not authorized to view this job"));
|
||||
}
|
||||
Ok(Some(GqlJob {
|
||||
id: ID(job.id.to_string()),
|
||||
original_filename: job.original_filename,
|
||||
source_format: job.source_format,
|
||||
target_format: job.target_format,
|
||||
engine: job.engine,
|
||||
status: job.status.into(),
|
||||
output_filename: job.output_filename,
|
||||
error_message: job.error_message,
|
||||
download_url: if job.status == JobStatus::Completed {
|
||||
Some(format!("/api/v1/jobs/{}/download", job.id))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
created_at: job.created_at,
|
||||
completed_at: job.completed_at,
|
||||
}))
|
||||
}
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate if a conversion is supported
|
||||
async fn validate_conversion(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
engine: String,
|
||||
from: String,
|
||||
to: String,
|
||||
) -> GqlResult<CreateJobResult> {
|
||||
let _user = validate_auth(ctx)?;
|
||||
let registry = ctx.data::<Arc<EngineRegistry>>()?;
|
||||
|
||||
match registry.validate_conversion(&engine, &from, &to) {
|
||||
Ok(_) => Ok(CreateJobResult {
|
||||
success: true,
|
||||
job: None,
|
||||
error: None,
|
||||
}),
|
||||
Err(crate::error::ApiError::UnsupportedConversion {
|
||||
engine,
|
||||
from,
|
||||
to,
|
||||
suggestions,
|
||||
}) => Ok(CreateJobResult {
|
||||
success: false,
|
||||
job: None,
|
||||
error: Some(GqlConversionError {
|
||||
code: "UNSUPPORTED_CONVERSION".to_string(),
|
||||
message: format!(
|
||||
"Conversion from {} to {} is not supported by engine {}",
|
||||
from, to, engine
|
||||
),
|
||||
suggestions: suggestions.into_iter().map(Into::into).collect(),
|
||||
}),
|
||||
}),
|
||||
Err(e) => Err(async_graphql::Error::new(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get suggestions for a conversion
|
||||
async fn suggestions(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
from: String,
|
||||
to: String,
|
||||
) -> GqlResult<Vec<GqlSuggestion>> {
|
||||
let _user = validate_auth(ctx)?;
|
||||
let registry = ctx.data::<Arc<EngineRegistry>>()?;
|
||||
|
||||
let suggestions = registry.find_suggestions(&from, &to);
|
||||
Ok(suggestions.into_iter().map(Into::into).collect())
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Mutation Root ============
|
||||
|
||||
pub struct MutationRoot;
|
||||
|
||||
#[Object]
|
||||
impl MutationRoot {
|
||||
/// Create a new conversion job
|
||||
///
|
||||
/// Note: File upload via GraphQL requires multipart form handling.
|
||||
/// For file uploads, the REST API endpoint is recommended.
|
||||
/// This mutation accepts base64-encoded file data for simpler integration.
|
||||
async fn create_job(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
/// Original filename
|
||||
filename: String,
|
||||
/// Base64-encoded file content
|
||||
file_base64: String,
|
||||
/// Conversion input parameters
|
||||
input: CreateJobInput,
|
||||
) -> GqlResult<CreateJobResult> {
|
||||
let user = validate_auth(ctx)?;
|
||||
let service = ctx.data::<Arc<ConversionService>>()?;
|
||||
|
||||
// Decode base64 file content
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
let file_data = STANDARD.decode(&file_base64)
|
||||
.map_err(|e| async_graphql::Error::new(format!("Invalid base64 encoding: {}", e)))?;
|
||||
|
||||
// Parse options if provided
|
||||
let options = if let Some(opts_str) = input.options {
|
||||
Some(serde_json::from_str(&opts_str)
|
||||
.map_err(|e| async_graphql::Error::new(format!("Invalid options JSON: {}", e)))?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match service
|
||||
.create_job(
|
||||
user.user_id,
|
||||
filename,
|
||||
input.engine,
|
||||
input.target_format,
|
||||
options,
|
||||
file_data,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(job) => Ok(CreateJobResult {
|
||||
success: true,
|
||||
job: Some(GqlJob {
|
||||
id: ID(job.id.to_string()),
|
||||
original_filename: job.original_filename,
|
||||
source_format: job.source_format,
|
||||
target_format: job.target_format,
|
||||
engine: job.engine,
|
||||
status: job.status.into(),
|
||||
output_filename: job.output_filename,
|
||||
error_message: job.error_message,
|
||||
download_url: None,
|
||||
created_at: job.created_at,
|
||||
completed_at: job.completed_at,
|
||||
}),
|
||||
error: None,
|
||||
}),
|
||||
Err(crate::error::ApiError::UnsupportedConversion {
|
||||
engine,
|
||||
from,
|
||||
to,
|
||||
suggestions,
|
||||
}) => Ok(CreateJobResult {
|
||||
success: false,
|
||||
job: None,
|
||||
error: Some(GqlConversionError {
|
||||
code: "UNSUPPORTED_CONVERSION".to_string(),
|
||||
message: format!(
|
||||
"Conversion from {} to {} is not supported by engine {}",
|
||||
from, to, engine
|
||||
),
|
||||
suggestions: suggestions.into_iter().map(Into::into).collect(),
|
||||
}),
|
||||
}),
|
||||
Err(e) => Ok(CreateJobResult {
|
||||
success: false,
|
||||
job: None,
|
||||
error: Some(GqlConversionError {
|
||||
code: "ERROR".to_string(),
|
||||
message: e.to_string(),
|
||||
suggestions: vec![],
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a job
|
||||
async fn delete_job(&self, ctx: &Context<'_>, id: ID) -> GqlResult<bool> {
|
||||
let user = validate_auth(ctx)?;
|
||||
let service = ctx.data::<Arc<ConversionService>>()?;
|
||||
|
||||
let job_id = Uuid::parse_str(&id.0)
|
||||
.map_err(|_| async_graphql::Error::new("Invalid job ID format"))?;
|
||||
|
||||
service
|
||||
.delete_job(job_id, &user.user_id)
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_job_status_conversion() {
|
||||
assert_eq!(GqlJobStatus::from(JobStatus::Pending), GqlJobStatus::Pending);
|
||||
assert_eq!(GqlJobStatus::from(JobStatus::Processing), GqlJobStatus::Processing);
|
||||
assert_eq!(GqlJobStatus::from(JobStatus::Completed), GqlJobStatus::Completed);
|
||||
assert_eq!(GqlJobStatus::from(JobStatus::Failed), GqlJobStatus::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_routes_are_defined() {
|
||||
let _routes = routes();
|
||||
}
|
||||
}
|
||||
61
api-server/src/lib.rs
Normal file
61
api-server/src/lib.rs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
//! ConvertX API Server - A REST and GraphQL file conversion API
|
||||
//!
|
||||
//! This module provides the main entry point for the API server.
|
||||
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod engine;
|
||||
pub mod conversion;
|
||||
pub mod rest;
|
||||
pub mod graphql;
|
||||
pub mod models;
|
||||
|
||||
use std::sync::Arc;
|
||||
use axum::Router;
|
||||
use tower_http::cors::{CorsLayer, Any};
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::engine::EngineRegistry;
|
||||
use crate::conversion::ConversionService;
|
||||
|
||||
/// Application state shared across all handlers
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub config: Arc<Config>,
|
||||
pub engine_registry: Arc<EngineRegistry>,
|
||||
pub conversion_service: Arc<ConversionService>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(config: Config) -> Self {
|
||||
let config = Arc::new(config);
|
||||
let engine_registry = Arc::new(EngineRegistry::new());
|
||||
let conversion_service = Arc::new(ConversionService::new(
|
||||
engine_registry.clone(),
|
||||
config.clone(),
|
||||
));
|
||||
|
||||
Self {
|
||||
config,
|
||||
engine_registry,
|
||||
conversion_service,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the application router with all routes
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
Router::new()
|
||||
.merge(rest::routes())
|
||||
.merge(graphql::routes())
|
||||
.layer(cors)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
}
|
||||
44
api-server/src/main.rs
Normal file
44
api-server/src/main.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
//! ConvertX API Server - Main entry point
|
||||
//!
|
||||
//! A REST and GraphQL API server for file conversion operations.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use convertx_api::{build_router, config::Config, AppState};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Initialize logging
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "convertx_api=debug,tower_http=debug".into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
// Load configuration
|
||||
dotenvy::dotenv().ok();
|
||||
let config = Config::from_env()?;
|
||||
|
||||
tracing::info!("Starting ConvertX API Server");
|
||||
tracing::info!("REST API: http://{}:{}/api/v1", config.host, config.port);
|
||||
tracing::info!("GraphQL Playground: http://{}:{}/graphql", config.host, config.port);
|
||||
|
||||
// Build application state
|
||||
let state = AppState::new(config.clone());
|
||||
|
||||
// Build router
|
||||
let app = build_router(state);
|
||||
|
||||
// Start server
|
||||
let addr = SocketAddr::new(config.host.parse()?, config.port);
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
|
||||
tracing::info!("Server listening on {}", addr);
|
||||
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
215
api-server/src/models.rs
Normal file
215
api-server/src/models.rs
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
//! Models module - Data structures for API requests and responses
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// Job status enumeration
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum JobStatus {
|
||||
Pending,
|
||||
Processing,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for JobStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
JobStatus::Pending => write!(f, "pending"),
|
||||
JobStatus::Processing => write!(f, "processing"),
|
||||
JobStatus::Completed => write!(f, "completed"),
|
||||
JobStatus::Failed => write!(f, "failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversion job representation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConversionJob {
|
||||
/// Unique job identifier
|
||||
pub id: Uuid,
|
||||
/// User who created the job
|
||||
pub user_id: String,
|
||||
/// Original filename
|
||||
pub original_filename: String,
|
||||
/// Source file format
|
||||
pub source_format: String,
|
||||
/// Target file format
|
||||
pub target_format: String,
|
||||
/// Engine used for conversion
|
||||
pub engine: String,
|
||||
/// Current job status
|
||||
pub status: JobStatus,
|
||||
/// Output filename (when completed)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_filename: Option<String>,
|
||||
/// Error message (when failed)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_message: Option<String>,
|
||||
/// Job creation timestamp
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// Job completion timestamp
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
/// Conversion options
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub options: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl ConversionJob {
|
||||
/// Create a new pending conversion job
|
||||
pub fn new(
|
||||
user_id: String,
|
||||
original_filename: String,
|
||||
source_format: String,
|
||||
target_format: String,
|
||||
engine: String,
|
||||
options: Option<serde_json::Value>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
user_id,
|
||||
original_filename,
|
||||
source_format,
|
||||
target_format,
|
||||
engine,
|
||||
status: JobStatus::Pending,
|
||||
output_filename: None,
|
||||
error_message: None,
|
||||
created_at: Utc::now(),
|
||||
completed_at: None,
|
||||
options,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark job as processing
|
||||
pub fn set_processing(&mut self) {
|
||||
self.status = JobStatus::Processing;
|
||||
}
|
||||
|
||||
/// Mark job as completed
|
||||
pub fn set_completed(&mut self, output_filename: String) {
|
||||
self.status = JobStatus::Completed;
|
||||
self.output_filename = Some(output_filename);
|
||||
self.completed_at = Some(Utc::now());
|
||||
}
|
||||
|
||||
/// Mark job as failed
|
||||
pub fn set_failed(&mut self, error_message: String) {
|
||||
self.status = JobStatus::Failed;
|
||||
self.error_message = Some(error_message);
|
||||
self.completed_at = Some(Utc::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// Request to create a conversion job
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateJobRequest {
|
||||
/// Engine to use for conversion (required)
|
||||
pub engine: String,
|
||||
/// Target format (required)
|
||||
pub target_format: String,
|
||||
/// Optional conversion options
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub options: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Response after creating a conversion job
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateJobResponse {
|
||||
/// Job ID
|
||||
pub job_id: Uuid,
|
||||
/// Current status
|
||||
pub status: JobStatus,
|
||||
/// Message
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Job status response
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JobStatusResponse {
|
||||
/// Job ID
|
||||
pub job_id: Uuid,
|
||||
/// Current status
|
||||
pub status: JobStatus,
|
||||
/// Original filename
|
||||
pub original_filename: String,
|
||||
/// Source format
|
||||
pub source_format: String,
|
||||
/// Target format
|
||||
pub target_format: String,
|
||||
/// Engine used
|
||||
pub engine: String,
|
||||
/// Download URL (when completed)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub download_url: Option<String>,
|
||||
/// Error message (when failed)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_message: Option<String>,
|
||||
/// Creation time
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// Completion time
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl From<&ConversionJob> for JobStatusResponse {
|
||||
fn from(job: &ConversionJob) -> Self {
|
||||
let download_url = if job.status == JobStatus::Completed {
|
||||
Some(format!("/api/v1/jobs/{}/download", job.id))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Self {
|
||||
job_id: job.id,
|
||||
status: job.status,
|
||||
original_filename: job.original_filename.clone(),
|
||||
source_format: job.source_format.clone(),
|
||||
target_format: job.target_format.clone(),
|
||||
engine: job.engine.clone(),
|
||||
download_url,
|
||||
error_message: job.error_message.clone(),
|
||||
created_at: job.created_at,
|
||||
completed_at: job.completed_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Engine information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EngineInfo {
|
||||
/// Engine identifier
|
||||
pub id: String,
|
||||
/// Human-readable name
|
||||
pub name: String,
|
||||
/// Description
|
||||
pub description: String,
|
||||
/// Supported input formats
|
||||
pub supported_input_formats: Vec<String>,
|
||||
/// Supported output formats
|
||||
pub supported_output_formats: Vec<String>,
|
||||
}
|
||||
|
||||
/// List engines response
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ListEnginesResponse {
|
||||
pub engines: Vec<EngineInfo>,
|
||||
}
|
||||
|
||||
/// Health check response
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HealthResponse {
|
||||
pub status: String,
|
||||
pub version: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// List jobs response
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ListJobsResponse {
|
||||
pub jobs: Vec<JobStatusResponse>,
|
||||
pub total: usize,
|
||||
}
|
||||
278
api-server/src/rest.rs
Normal file
278
api-server/src/rest.rs
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
//! REST API module
|
||||
//!
|
||||
//! Provides REST endpoints for file conversion operations.
|
||||
|
||||
use axum::{
|
||||
extract::{Multipart, Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::json;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::RequireAuth;
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::models::{
|
||||
CreateJobResponse, HealthResponse, JobStatusResponse, ListEnginesResponse, ListJobsResponse,
|
||||
};
|
||||
use crate::AppState;
|
||||
|
||||
/// Create REST API routes
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
// Health check (no auth required)
|
||||
.route("/health", get(health_check))
|
||||
.route("/api/v1/health", get(health_check))
|
||||
// Protected routes
|
||||
.route("/api/v1/engines", get(list_engines))
|
||||
.route("/api/v1/engines/:engine_id", get(get_engine))
|
||||
.route("/api/v1/engines/:engine_id/conversions", get(get_engine_conversions))
|
||||
.route("/api/v1/convert", post(create_conversion_job))
|
||||
.route("/api/v1/jobs", get(list_jobs))
|
||||
.route("/api/v1/jobs/:job_id", get(get_job_status))
|
||||
.route("/api/v1/jobs/:job_id", delete(delete_job))
|
||||
.route("/api/v1/jobs/:job_id/download", get(download_result))
|
||||
}
|
||||
|
||||
/// Health check endpoint
|
||||
async fn health_check() -> Json<HealthResponse> {
|
||||
Json(HealthResponse {
|
||||
status: "healthy".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
})
|
||||
}
|
||||
|
||||
/// List all available conversion engines
|
||||
async fn list_engines(
|
||||
State(state): State<AppState>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
) -> Json<ListEnginesResponse> {
|
||||
let engines = state.engine_registry.list_info();
|
||||
Json(ListEnginesResponse { engines })
|
||||
}
|
||||
|
||||
/// Get a specific engine's details
|
||||
async fn get_engine(
|
||||
State(state): State<AppState>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(engine_id): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
let engine = state
|
||||
.engine_registry
|
||||
.get(&engine_id)
|
||||
.ok_or_else(|| ApiError::EngineNotFound(engine_id.clone()))?;
|
||||
|
||||
Ok(Json(engine.to_info()))
|
||||
}
|
||||
|
||||
/// Get supported conversions for an engine
|
||||
async fn get_engine_conversions(
|
||||
State(state): State<AppState>,
|
||||
RequireAuth(_user): RequireAuth,
|
||||
Path(engine_id): Path<String>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
let engine = state
|
||||
.engine_registry
|
||||
.get(&engine_id)
|
||||
.ok_or_else(|| ApiError::EngineNotFound(engine_id.clone()))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"engine_id": engine.id,
|
||||
"conversions": engine.conversions,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Create a new conversion job
|
||||
///
|
||||
/// Expects multipart form data with:
|
||||
/// - file: The file to convert
|
||||
/// - engine: The conversion engine to use
|
||||
/// - target_format: The target format
|
||||
/// - options: (optional) JSON string of conversion options
|
||||
async fn create_conversion_job(
|
||||
State(state): State<AppState>,
|
||||
RequireAuth(user): RequireAuth,
|
||||
mut multipart: Multipart,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
let mut file_data: Option<Vec<u8>> = None;
|
||||
let mut filename: Option<String> = None;
|
||||
let mut engine: Option<String> = None;
|
||||
let mut target_format: Option<String> = None;
|
||||
let mut options: Option<serde_json::Value> = None;
|
||||
|
||||
// Parse multipart form data
|
||||
while let Some(field) = multipart.next_field().await
|
||||
.map_err(|e| ApiError::BadRequest(format!("Failed to read multipart field: {}", e)))?
|
||||
{
|
||||
let name = field.name().unwrap_or_default().to_string();
|
||||
|
||||
match name.as_str() {
|
||||
"file" => {
|
||||
filename = field.file_name().map(|s| s.to_string());
|
||||
let data = field.bytes().await
|
||||
.map_err(|e| ApiError::BadRequest(format!("Failed to read file: {}", e)))?;
|
||||
|
||||
// Check file size
|
||||
if data.len() > state.config.max_file_size {
|
||||
return Err(ApiError::FileTooLarge {
|
||||
max_size: state.config.max_file_size,
|
||||
});
|
||||
}
|
||||
|
||||
file_data = Some(data.to_vec());
|
||||
}
|
||||
"engine" => {
|
||||
let text = field.text().await
|
||||
.map_err(|e| ApiError::BadRequest(format!("Failed to read engine: {}", e)))?;
|
||||
engine = Some(text);
|
||||
}
|
||||
"target_format" => {
|
||||
let text = field.text().await
|
||||
.map_err(|e| ApiError::BadRequest(format!("Failed to read target_format: {}", e)))?;
|
||||
target_format = Some(text);
|
||||
}
|
||||
"options" => {
|
||||
let text = field.text().await
|
||||
.map_err(|e| ApiError::BadRequest(format!("Failed to read options: {}", e)))?;
|
||||
if !text.is_empty() {
|
||||
options = Some(serde_json::from_str(&text)
|
||||
.map_err(|e| ApiError::BadRequest(format!("Invalid options JSON: {}", e)))?);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Ignore unknown fields
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
let file_data = file_data.ok_or_else(|| ApiError::BadRequest("Missing file".into()))?;
|
||||
let filename = filename.ok_or_else(|| ApiError::BadRequest("Missing filename".into()))?;
|
||||
let engine = engine.ok_or_else(|| ApiError::BadRequest("Missing engine parameter".into()))?;
|
||||
let target_format = target_format
|
||||
.ok_or_else(|| ApiError::BadRequest("Missing target_format parameter".into()))?;
|
||||
|
||||
// Create the conversion job
|
||||
let job = state
|
||||
.conversion_service
|
||||
.create_job(user.user_id, filename, engine, target_format, options, file_data)
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(CreateJobResponse {
|
||||
job_id: job.id,
|
||||
status: job.status,
|
||||
message: "Conversion job created successfully".to_string(),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// List all jobs for the authenticated user
|
||||
async fn list_jobs(
|
||||
State(state): State<AppState>,
|
||||
RequireAuth(user): RequireAuth,
|
||||
) -> Json<ListJobsResponse> {
|
||||
let jobs = state.conversion_service.get_user_jobs(&user.user_id).await;
|
||||
let job_responses: Vec<JobStatusResponse> = jobs.iter().map(|j| j.into()).collect();
|
||||
let total = job_responses.len();
|
||||
|
||||
Json(ListJobsResponse {
|
||||
jobs: job_responses,
|
||||
total,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the status of a specific job
|
||||
async fn get_job_status(
|
||||
State(state): State<AppState>,
|
||||
RequireAuth(user): RequireAuth,
|
||||
Path(job_id): Path<Uuid>,
|
||||
) -> ApiResult<Json<JobStatusResponse>> {
|
||||
let job = state.conversion_service.get_job(job_id).await?;
|
||||
|
||||
// Verify ownership
|
||||
if job.user_id != user.user_id {
|
||||
return Err(ApiError::Unauthorized("Not authorized to view this job".into()));
|
||||
}
|
||||
|
||||
Ok(Json((&job).into()))
|
||||
}
|
||||
|
||||
/// Delete a job
|
||||
async fn delete_job(
|
||||
State(state): State<AppState>,
|
||||
RequireAuth(user): RequireAuth,
|
||||
Path(job_id): Path<Uuid>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
state
|
||||
.conversion_service
|
||||
.delete_job(job_id, &user.user_id)
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"message": "Job deleted successfully",
|
||||
"job_id": job_id,
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
/// Download the converted file
|
||||
async fn download_result(
|
||||
State(state): State<AppState>,
|
||||
RequireAuth(user): RequireAuth,
|
||||
Path(job_id): Path<Uuid>,
|
||||
) -> ApiResult<impl IntoResponse> {
|
||||
let file_path = state
|
||||
.conversion_service
|
||||
.get_output_file(job_id, &user.user_id)
|
||||
.await?;
|
||||
|
||||
// Read file
|
||||
let mut file = File::open(&file_path).await
|
||||
.map_err(|e| ApiError::InternalError(format!("Failed to open file: {}", e)))?;
|
||||
|
||||
let mut contents = Vec::new();
|
||||
file.read_to_end(&mut contents).await
|
||||
.map_err(|e| ApiError::InternalError(format!("Failed to read file: {}", e)))?;
|
||||
|
||||
// Determine content type
|
||||
let content_type = mime_guess::from_path(&file_path)
|
||||
.first_or_octet_stream()
|
||||
.to_string();
|
||||
|
||||
let filename = file_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("download");
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[
|
||||
("content-type", content_type),
|
||||
(
|
||||
"content-disposition",
|
||||
format!("attachment; filename=\"{}\"", filename),
|
||||
),
|
||||
],
|
||||
contents,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_routes_are_defined() {
|
||||
// Just verify routes can be created
|
||||
let _routes = routes();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue