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

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"));
}
}