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:
Your Name 2026-01-21 11:40:03 +08:00
parent d0388066a5
commit e083e5d11d
18 changed files with 5235 additions and 0 deletions

287
api-server/src/auth.rs Normal file
View 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
View 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,
}
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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();
}
}