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

19
api-server/.env.example Normal file
View file

@ -0,0 +1,19 @@
# API Server 環境設定範例
# 複製此檔案為 .env 並依需求修改
# 伺服器設定
API_HOST=0.0.0.0
API_PORT=3001
# JWT 設定(正式環境請務必更改!)
JWT_SECRET=your-super-secret-jwt-key-change-in-production
# 檔案儲存
UPLOAD_DIR=./data/uploads
OUTPUT_DIR=./data/output
# 限制
MAX_FILE_SIZE=104857600
# JWT Token 過期時間(秒),用於驗證參考
JWT_EXPIRATION_SECS=86400

25
api-server/.gitignore vendored Normal file
View file

@ -0,0 +1,25 @@
# Environment variables
.env
.env.local
.env.*.local
# Build artifacts
/target/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
# Test data
/test_data/
/data/
# Logs
*.log

63
api-server/Cargo.toml Normal file
View file

@ -0,0 +1,63 @@
[package]
name = "convertx-api"
version = "0.1.0"
edition = "2021"
authors = ["ConvertX Team"]
description = "ConvertX API Server - REST & GraphQL file conversion API"
license = "MIT"
repository = "https://github.com/your-username/ConvertX-CN"
[dependencies]
# Web framework
axum = { version = "0.7", features = ["multipart"] }
tokio = { version = "1.0", features = ["full"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "trace", "fs"] }
# GraphQL
async-graphql = { version = "7.0", features = ["uuid"] }
async-graphql-axum = "7.0"
# Authentication
jsonwebtoken = "9.0"
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Async utilities
futures = "0.3"
async-trait = "0.1"
# UUID for job IDs
uuid = { version = "1.0", features = ["v4", "serde"] }
# File handling
mime_guess = "2.0"
tempfile = "3.0"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Error handling
thiserror = "1.0"
anyhow = "1.0"
# Time
chrono = { version = "0.4", features = ["serde"] }
# Base64 encoding
base64 = "0.22"
# Configuration
dotenvy = "0.15"
# Process execution (for calling converters)
tokio-process = "0.2"
[dev-dependencies]
axum-test = "14.0"
tokio-test = "0.4"
reqwest = { version = "0.11", features = ["json", "multipart"] }
pretty_assertions = "1.0"

557
api-server/README.md Normal file
View file

@ -0,0 +1,557 @@
# ConvertX API Server
一個使用 Rust 實作的 REST 與 GraphQL 檔案轉換 API 伺服器。
## 🎯 功能特色
- **雙 API 支援**: 同時提供 REST API 和 GraphQL API兩者完全獨立運作
- **JWT 認證**: 所有 API 請求都需要 JWT Bearer Token 驗證
- **多引擎支援**: 整合 20+ 種轉換引擎FFmpeg、ImageMagick、LibreOffice 等)
- **智慧建議**: 當轉換不支援時,自動回傳可用的替代方案
- **完整測試**: 包含單元測試和 API 整合測試
## 🏗️ 系統架構
```
API Server
├─ Auth Layer (JWT 驗證)
├─ REST API (/api/v1/*)
├─ GraphQL API (/graphql)
└─ Conversion Service (Domain Logic)
└─ Engine Registry (轉換引擎管理)
```
### 設計原則
- **API Server 只負責**:
- 驗證 JWT
- 驗證請求是否合法
- 建立轉檔任務
- 呼叫轉檔引擎
- 回傳結果 / 錯誤 / 建議
- **轉換引擎必須明確指定**: 使用者必須指定要使用的引擎,系統不會自動選擇
## 🚀 快速開始
### 環境需求
- Rust 1.75+
- 對應的轉換工具FFmpeg、ImageMagick 等,視需求安裝)
### 安裝與執行
```bash
# 進入 API Server 目錄
cd api-server
# 編譯
cargo build --release
# 執行
cargo run --release
```
### 環境變數
| 變數 | 說明 | 預設值 |
|------|------|--------|
| `API_HOST` | 伺服器監聽地址 | `0.0.0.0` |
| `API_PORT` | 伺服器監聽埠 | `3001` |
| `JWT_SECRET` | JWT 驗證密鑰 | (預設值,正式環境請更改) |
| `UPLOAD_DIR` | 上傳檔案目錄 | `./data/uploads` |
| `OUTPUT_DIR` | 輸出檔案目錄 | `./data/output` |
| `MAX_FILE_SIZE` | 最大檔案大小bytes | `104857600` (100MB) |
### 範例 .env 檔案
```env
API_HOST=0.0.0.0
API_PORT=3001
JWT_SECRET=your-super-secret-jwt-key-change-in-production
UPLOAD_DIR=./data/uploads
OUTPUT_DIR=./data/output
MAX_FILE_SIZE=104857600
```
## 🔐 認證機制
所有 API 請求(除了健康檢查)都需要 JWT Bearer Token
```
Authorization: Bearer <your-jwt-token>
```
### JWT Claims 結構
```json
{
"sub": "user-id",
"exp": 1234567890,
"iat": 1234567890,
"email": "user@example.com",
"roles": ["user"]
}
```
**注意**: API Server 只負責驗證 JWT不負責產生 JWT。Token 應由獨立的認證服務產生。
## 📖 REST API
### 基礎 URL
```
http://localhost:3001/api/v1
```
### Endpoints
#### 健康檢查
```http
GET /health
GET /api/v1/health
```
回應:
```json
{
"status": "healthy",
"version": "0.1.0",
"timestamp": "2024-01-01T00:00:00Z"
}
```
#### 列出所有引擎
```http
GET /api/v1/engines
Authorization: Bearer <token>
```
回應:
```json
{
"engines": [
{
"id": "ffmpeg",
"name": "FFmpeg",
"description": "Audio and video conversion using FFmpeg",
"supported_input_formats": ["mp4", "webm", "avi", ...],
"supported_output_formats": ["mp4", "webm", "mp3", ...]
}
]
}
```
#### 取得特定引擎資訊
```http
GET /api/v1/engines/:engine_id
Authorization: Bearer <token>
```
#### 建立轉檔任務
```http
POST /api/v1/convert
Authorization: Bearer <token>
Content-Type: multipart/form-data
```
表單欄位:
- `file`: 要轉換的檔案(必填)
- `engine`: 轉換引擎 ID必填
- `target_format`: 目標格式(必填)
- `options`: JSON 格式的選項(選填)
回應:
```json
{
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "pending",
"message": "Conversion job created successfully"
}
```
#### 列出使用者的任務
```http
GET /api/v1/jobs
Authorization: Bearer <token>
```
#### 取得任務狀態
```http
GET /api/v1/jobs/:job_id
Authorization: Bearer <token>
```
回應:
```json
{
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "completed",
"original_filename": "video.mp4",
"source_format": "mp4",
"target_format": "webm",
"engine": "ffmpeg",
"download_url": "/api/v1/jobs/550e8400-.../download",
"created_at": "2024-01-01T00:00:00Z",
"completed_at": "2024-01-01T00:01:00Z"
}
```
#### 下載轉換結果
```http
GET /api/v1/jobs/:job_id/download
Authorization: Bearer <token>
```
#### 刪除任務
```http
DELETE /api/v1/jobs/:job_id
Authorization: Bearer <token>
```
### cURL 範例
```bash
# 健康檢查
curl http://localhost:3001/health
# 列出引擎
curl -H "Authorization: Bearer $TOKEN" http://localhost:3001/api/v1/engines
# 建立轉檔任務
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-F "file=@video.mp4" \
-F "engine=ffmpeg" \
-F "target_format=webm" \
http://localhost:3001/api/v1/convert
# 查詢任務狀態
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:3001/api/v1/jobs/$JOB_ID
# 下載結果
curl -H "Authorization: Bearer $TOKEN" \
-o result.webm \
http://localhost:3001/api/v1/jobs/$JOB_ID/download
```
## 📊 GraphQL API
### Endpoint
```
http://localhost:3001/graphql
```
GraphQL Playground 可透過瀏覽器訪問 `http://localhost:3001/graphql`
### Schema
#### Queries
```graphql
type Query {
# 健康檢查(不需認證)
health: Health!
# 列出所有引擎
engines: [Engine!]!
# 取得特定引擎
engine(id: ID!): Engine
# 列出使用者的任務
jobs: [Job!]!
# 取得特定任務
job(id: ID!): Job
# 驗證轉換是否支援
validateConversion(engine: String!, from: String!, to: String!): CreateJobResult!
# 取得轉換建議
suggestions(from: String!, to: String!): [Suggestion!]!
}
```
#### Mutations
```graphql
type Mutation {
# 建立轉檔任務
createJob(
filename: String!
fileBase64: String!
input: CreateJobInput!
): CreateJobResult!
# 刪除任務
deleteJob(id: ID!): Boolean!
}
input CreateJobInput {
engine: String!
targetFormat: String!
options: String
}
```
#### Types
```graphql
type Engine {
id: ID!
name: String!
description: String!
supportedInputFormats: [String!]!
supportedOutputFormats: [String!]!
}
type Job {
id: ID!
originalFilename: String!
sourceFormat: String!
targetFormat: String!
engine: String!
status: JobStatus!
outputFilename: String
errorMessage: String
downloadUrl: String
createdAt: DateTime!
completedAt: DateTime
}
enum JobStatus {
PENDING
PROCESSING
COMPLETED
FAILED
}
type Suggestion {
engine: String!
from: String!
to: String!
}
type CreateJobResult {
success: Boolean!
job: Job
error: ConversionError
}
type ConversionError {
code: String!
message: String!
suggestions: [Suggestion!]!
}
```
### GraphQL 範例
```graphql
# 列出所有引擎
query {
engines {
id
name
supportedInputFormats
supportedOutputFormats
}
}
# 驗證轉換是否支援
query {
validateConversion(engine: "ffmpeg", from: "mp4", to: "webm") {
success
error {
code
message
suggestions {
engine
from
to
}
}
}
}
# 建立轉檔任務
mutation {
createJob(
filename: "video.mp4"
fileBase64: "base64-encoded-content"
input: {
engine: "ffmpeg"
targetFormat: "webm"
}
) {
success
job {
id
status
}
error {
code
message
suggestions {
engine
from
to
}
}
}
}
# 查詢任務
query {
jobs {
id
status
originalFilename
downloadUrl
}
}
```
## ❌ 錯誤處理
### 錯誤回應格式
```json
{
"error": {
"code": "UNSUPPORTED_CONVERSION",
"message": "Conversion from pdf to mp4 is not supported by engine ffmpeg",
"suggestions": [
{
"engine": "libreoffice",
"from": "pdf",
"to": "docx"
},
{
"engine": "calibre",
"from": "pdf",
"to": "epub"
}
]
}
}
```
### 錯誤碼
| 錯誤碼 | HTTP 狀態 | 說明 |
|--------|-----------|------|
| `UNAUTHORIZED` | 401 | 未授權 |
| `INVALID_TOKEN` | 401 | Token 格式或簽名無效 |
| `TOKEN_EXPIRED` | 401 | Token 已過期 |
| `MISSING_AUTH_HEADER` | 401 | 缺少 Authorization 標頭 |
| `BAD_REQUEST` | 400 | 請求格式錯誤 |
| `INVALID_FILE` | 400 | 檔案格式無法辨識 |
| `FILE_TOO_LARGE` | 400 | 檔案超過大小限制 |
| `ENGINE_NOT_FOUND` | 404 | 指定的引擎不存在 |
| `JOB_NOT_FOUND` | 404 | 任務不存在 |
| `FILE_NOT_FOUND` | 404 | 檔案不存在 |
| `UNSUPPORTED_CONVERSION` | 422 | 不支援的轉換(附帶建議) |
| `CONVERSION_FAILED` | 500 | 轉換過程失敗 |
| `INTERNAL_ERROR` | 500 | 內部錯誤 |
## 📦 轉換結果策略
本 API Server 採用 **選項 B結果僅透過 API 提供下載**
### 設計理由
1. **安全性**: 不暴露實體檔案路徑,避免路徑遍歷攻擊
2. **權限控制**: 下載時驗證 JWT確保只有任務擁有者能下載
3. **彈性部署**: 適合雲端環境,可輕易整合 CDN 或物件儲存
4. **清理管理**: 方便實作自動清理過期檔案的機制
### 檔案儲存
內部儲存結構:
```
data/
├── uploads/
│ └── <job_id>/
│ └── <original_filename>
└── output/
└── <job_id>/
└── <converted_filename>
```
使用者透過 `/api/v1/jobs/{job_id}/download` 下載API 會驗證權限後串流檔案。
## 🔧 支援的轉換引擎
| 引擎 ID | 名稱 | 說明 |
|---------|------|------|
| `ffmpeg` | FFmpeg | 音視頻轉換 |
| `imagemagick` | ImageMagick | 圖片格式轉換 |
| `graphicsmagick` | GraphicsMagick | 圖片格式轉換(替代方案) |
| `libreoffice` | LibreOffice | 辦公文件轉換 |
| `pandoc` | Pandoc | 文件/標記語言轉換 |
| `calibre` | Calibre | 電子書轉換 |
| `inkscape` | Inkscape | 向量圖轉換 |
| `resvg` | resvg | SVG 渲染 |
| `vips` | libvips | 高效能圖片處理 |
| `libheif` | libheif | HEIF/HEIC 轉換 |
| `libjxl` | libjxl | JPEG XL 轉換 |
| `potrace` | Potrace | 點陣圖轉向量 |
| `vtracer` | VTracer | 進階向量化 |
| `dasel` | Dasel | 資料格式轉換 |
| `assimp` | Assimp | 3D 模型轉換 |
| `xelatex` | XeLaTeX | LaTeX 編譯 |
| `dvisvgm` | dvisvgm | DVI 轉 SVG |
| `msgconvert` | msgconvert | Outlook MSG 轉 EML |
| `vcf` | VCF Converter | vCard 轉換 |
| `markitdown` | MarkItDown | 文件轉 Markdown |
## 🧪 測試
```bash
# 執行所有測試
cargo test
# 執行特定測試
cargo test auth_tests
cargo test graphql_tests
# 顯示測試輸出
cargo test -- --nocapture
```
## 📁 專案結構
```
api-server/
├── Cargo.toml
├── src/
│ ├── main.rs # 程式入口
│ ├── lib.rs # 函式庫模組
│ ├── config.rs # 設定管理
│ ├── auth.rs # JWT 認證
│ ├── error.rs # 錯誤處理
│ ├── models.rs # 資料模型
│ ├── engine.rs # 引擎註冊
│ ├── conversion.rs # 轉換服務
│ ├── rest.rs # REST API
│ └── graphql.rs # GraphQL API
└── tests/
├── api_tests.rs # REST API 測試
└── graphql_tests.rs # GraphQL 測試
```
## 📄 授權
MIT License

614
api-server/docs/API_SPEC.md Normal file
View file

@ -0,0 +1,614 @@
# ConvertX API 規格文件
## 概述
ConvertX API Server 是一個獨立的檔案轉換 API 服務,同時提供 REST API 和 GraphQL API。
兩種 API 是完全獨立的服務,使用者可以選擇只使用其中一種。
## 認證
### JWT Bearer Token
所有 API 請求(除了健康檢查)都需要在 HTTP Header 中提供有效的 JWT Token
```http
Authorization: Bearer <jwt-token>
```
### Token 結構
```json
{
"sub": "user-unique-id",
"exp": 1735689600,
"iat": 1735603200,
"email": "user@example.com",
"roles": ["user", "admin"]
}
```
| 欄位 | 必填 | 說明 |
|------|------|------|
| `sub` | ✓ | 使用者唯一識別碼 |
| `exp` | ✓ | Token 過期時間Unix timestamp |
| `iat` | ✓ | Token 簽發時間Unix timestamp |
| `email` | - | 使用者 Email |
| `roles` | - | 使用者角色列表 |
### 認證錯誤回應
| 狀況 | 錯誤碼 | HTTP 狀態 |
|------|--------|-----------|
| 缺少 Authorization Header | `MISSING_AUTH_HEADER` | 401 |
| Token 格式錯誤 | `INVALID_TOKEN` | 401 |
| Token 簽名無效 | `INVALID_TOKEN` | 401 |
| Token 已過期 | `TOKEN_EXPIRED` | 401 |
---
## REST API 規格
### 基礎資訊
- **Base URL**: `http://localhost:3001/api/v1`
- **Content-Type**: `application/json`(一般請求)或 `multipart/form-data`(檔案上傳)
---
### 健康檢查
#### `GET /health``GET /api/v1/health`
檢查 API Server 運作狀態。不需要認證。
**回應**
```json
{
"status": "healthy",
"version": "0.1.0",
"timestamp": "2024-01-15T10:30:00Z"
}
```
---
### 引擎管理
#### `GET /api/v1/engines`
列出所有可用的轉換引擎。
**Headers**
```
Authorization: Bearer <token>
```
**回應**
```json
{
"engines": [
{
"id": "ffmpeg",
"name": "FFmpeg",
"description": "Audio and video conversion using FFmpeg",
"supported_input_formats": ["mp4", "webm", "avi", "mkv", "mov", "mp3", "wav", "flac", "ogg", "m4a", "gif"],
"supported_output_formats": ["webm", "avi", "mkv", "mov", "mp3", "wav", "flac", "ogg", "gif", "m4a", "aac", "mp4"]
},
{
"id": "imagemagick",
"name": "ImageMagick",
"description": "Image format conversion using ImageMagick",
"supported_input_formats": ["png", "jpg", "jpeg", "gif", "bmp", "webp", "tiff", "svg"],
"supported_output_formats": ["jpg", "jpeg", "gif", "bmp", "webp", "tiff", "ico", "pdf", "png"]
}
]
}
```
---
#### `GET /api/v1/engines/:engine_id`
取得特定引擎的詳細資訊。
**參數**
- `engine_id`: 引擎識別碼(如 `ffmpeg`, `imagemagick`
**回應 (200)**
```json
{
"id": "ffmpeg",
"name": "FFmpeg",
"description": "Audio and video conversion using FFmpeg",
"supported_input_formats": ["mp4", "webm", "avi"],
"supported_output_formats": ["mp4", "webm", "mp3"]
}
```
**回應 (404)**
```json
{
"error": {
"code": "ENGINE_NOT_FOUND",
"message": "Engine not found: nonexistent"
}
}
```
---
#### `GET /api/v1/engines/:engine_id/conversions`
取得特定引擎支援的轉換對應表。
**回應**
```json
{
"engine_id": "ffmpeg",
"conversions": {
"mp4": ["webm", "avi", "mkv", "mov", "mp3", "wav", "flac", "ogg", "gif"],
"webm": ["mp4", "avi", "mkv", "mov", "mp3", "wav", "flac", "ogg", "gif"],
"mp3": ["wav", "flac", "ogg", "m4a", "aac"]
}
}
```
---
### 轉檔任務
#### `POST /api/v1/convert`
建立新的轉檔任務。
**Headers**
```
Authorization: Bearer <token>
Content-Type: multipart/form-data
```
**表單欄位**
| 欄位 | 類型 | 必填 | 說明 |
|------|------|------|------|
| `file` | File | ✓ | 要轉換的檔案 |
| `engine` | String | ✓ | 轉換引擎 ID |
| `target_format` | String | ✓ | 目標格式(不含點) |
| `options` | String | - | JSON 格式的轉換選項 |
**回應 (201)**
```json
{
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "pending",
"message": "Conversion job created successfully"
}
```
**回應 (422) - 不支援的轉換**
```json
{
"error": {
"code": "UNSUPPORTED_CONVERSION",
"message": "Unsupported conversion from pdf to mp4 using engine ffmpeg",
"suggestions": [
{
"engine": "libreoffice",
"from": "pdf",
"to": "docx"
},
{
"engine": "libreoffice",
"from": "pdf",
"to": "html"
},
{
"engine": "calibre",
"from": "pdf",
"to": "epub"
}
]
}
}
```
---
#### `GET /api/v1/jobs`
列出當前使用者的所有任務。
**回應**
```json
{
"jobs": [
{
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "completed",
"original_filename": "video.mp4",
"source_format": "mp4",
"target_format": "webm",
"engine": "ffmpeg",
"download_url": "/api/v1/jobs/550e8400-e29b-41d4-a716-446655440000/download",
"created_at": "2024-01-15T10:00:00Z",
"completed_at": "2024-01-15T10:01:30Z"
}
],
"total": 1
}
```
---
#### `GET /api/v1/jobs/:job_id`
取得特定任務的狀態。
**任務狀態**
| 狀態 | 說明 |
|------|------|
| `pending` | 等待處理 |
| `processing` | 處理中 |
| `completed` | 完成 |
| `failed` | 失敗 |
**回應 (200) - 完成**
```json
{
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "completed",
"original_filename": "video.mp4",
"source_format": "mp4",
"target_format": "webm",
"engine": "ffmpeg",
"download_url": "/api/v1/jobs/550e8400-e29b-41d4-a716-446655440000/download",
"created_at": "2024-01-15T10:00:00Z",
"completed_at": "2024-01-15T10:01:30Z"
}
```
**回應 (200) - 失敗**
```json
{
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "failed",
"original_filename": "corrupted.mp4",
"source_format": "mp4",
"target_format": "webm",
"engine": "ffmpeg",
"error_message": "Conversion failed: Invalid data found when processing input",
"created_at": "2024-01-15T10:00:00Z",
"completed_at": "2024-01-15T10:00:05Z"
}
```
---
#### `GET /api/v1/jobs/:job_id/download`
下載轉換後的檔案。
**前提條件**
- 任務狀態必須是 `completed`
- 只有任務建立者可以下載
**回應 Headers**
```
Content-Type: <mime-type>
Content-Disposition: attachment; filename="output.webm"
```
**錯誤回應**
| 狀況 | 錯誤碼 | HTTP 狀態 |
|------|--------|-----------|
| 任務未完成 | `BAD_REQUEST` | 400 |
| 無權限 | `UNAUTHORIZED` | 401 |
| 任務不存在 | `JOB_NOT_FOUND` | 404 |
| 檔案遺失 | `FILE_NOT_FOUND` | 404 |
---
#### `DELETE /api/v1/jobs/:job_id`
刪除任務及相關檔案。
**回應 (200)**
```json
{
"message": "Job deleted successfully",
"job_id": "550e8400-e29b-41d4-a716-446655440000"
}
```
---
## GraphQL API 規格
### Endpoint
- **URL**: `http://localhost:3001/graphql`
- **Method**: POST
- **Content-Type**: `application/json`
GraphQL Playground 可透過 GET 請求訪問同一 URL。
### Schema
```graphql
# ========== Queries ==========
type Query {
"""健康檢查(不需認證)"""
health: Health!
"""列出所有可用引擎"""
engines: [Engine!]!
"""取得特定引擎"""
engine(id: ID!): Engine
"""列出當前使用者的所有任務"""
jobs: [Job!]!
"""取得特定任務"""
job(id: ID!): Job
"""驗證轉換是否支援"""
validateConversion(
engine: String!
from: String!
to: String!
): CreateJobResult!
"""取得轉換建議"""
suggestions(from: String!, to: String!): [Suggestion!]!
}
# ========== Mutations ==========
type Mutation {
"""建立轉檔任務"""
createJob(
filename: String!
fileBase64: String!
input: CreateJobInput!
): CreateJobResult!
"""刪除任務"""
deleteJob(id: ID!): Boolean!
}
# ========== Input Types ==========
input CreateJobInput {
"""轉換引擎 ID"""
engine: String!
"""目標格式"""
targetFormat: String!
"""轉換選項JSON 字串)"""
options: String
}
# ========== Object Types ==========
type Health {
status: String!
version: String!
timestamp: DateTime!
}
type Engine {
id: ID!
name: String!
description: String!
supportedInputFormats: [String!]!
supportedOutputFormats: [String!]!
}
type Job {
id: ID!
originalFilename: String!
sourceFormat: String!
targetFormat: String!
engine: String!
status: JobStatus!
outputFilename: String
errorMessage: String
downloadUrl: String
createdAt: DateTime!
completedAt: DateTime
}
type Suggestion {
engine: String!
from: String!
to: String!
}
type CreateJobResult {
success: Boolean!
job: Job
error: ConversionError
}
type ConversionError {
code: String!
message: String!
suggestions: [Suggestion!]!
}
# ========== Enums ==========
enum JobStatus {
PENDING
PROCESSING
COMPLETED
FAILED
}
# ========== Scalars ==========
scalar DateTime
```
### 使用範例
#### 列出所有引擎
```graphql
query {
engines {
id
name
description
supportedInputFormats
supportedOutputFormats
}
}
```
#### 驗證轉換是否支援
```graphql
query {
validateConversion(engine: "ffmpeg", from: "mp4", to: "webm") {
success
error {
code
message
suggestions {
engine
from
to
}
}
}
}
```
#### 取得轉換建議
```graphql
query {
suggestions(from: "pdf", to: "docx") {
engine
from
to
}
}
```
#### 建立轉檔任務
```graphql
mutation CreateConversionJob($filename: String!, $content: String!) {
createJob(
filename: $filename
fileBase64: $content
input: {
engine: "imagemagick"
targetFormat: "jpg"
}
) {
success
job {
id
status
originalFilename
}
error {
code
message
suggestions {
engine
from
to
}
}
}
}
```
Variables:
```json
{
"filename": "image.png",
"content": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
}
```
#### 查詢任務狀態
```graphql
query {
job(id: "550e8400-e29b-41d4-a716-446655440000") {
id
status
originalFilename
sourceFormat
targetFormat
engine
downloadUrl
errorMessage
createdAt
completedAt
}
}
```
#### 列出所有任務
```graphql
query {
jobs {
id
status
originalFilename
targetFormat
downloadUrl
}
}
```
#### 刪除任務
```graphql
mutation {
deleteJob(id: "550e8400-e29b-41d4-a716-446655440000")
}
```
---
## 錯誤碼對照表
| 錯誤碼 | HTTP 狀態 | 說明 | 包含建議 |
|--------|-----------|------|----------|
| `UNAUTHORIZED` | 401 | 未授權存取 | ✗ |
| `INVALID_TOKEN` | 401 | Token 無效 | ✗ |
| `TOKEN_EXPIRED` | 401 | Token 已過期 | ✗ |
| `MISSING_AUTH_HEADER` | 401 | 缺少認證標頭 | ✗ |
| `BAD_REQUEST` | 400 | 請求格式錯誤 | ✗ |
| `INVALID_FILE` | 400 | 無法辨識檔案格式 | ✗ |
| `FILE_TOO_LARGE` | 400 | 檔案超過大小限制 | ✗ |
| `ENGINE_NOT_FOUND` | 404 | 引擎不存在 | ✗ |
| `JOB_NOT_FOUND` | 404 | 任務不存在 | ✗ |
| `FILE_NOT_FOUND` | 404 | 檔案不存在 | ✗ |
| `UNSUPPORTED_CONVERSION` | 422 | 不支援的轉換 | ✓ |
| `CONVERSION_FAILED` | 500 | 轉換失敗 | ✗ |
| `INTERNAL_ERROR` | 500 | 內部錯誤 | ✗ |

View file

@ -0,0 +1,319 @@
# ConvertX API 架構說明
## 系統架構圖
```
┌─────────────────────────────────────────────────────────────────┐
│ API Server │
│ │
│ ┌──────────────┐ │
│ │ Auth Layer │◄──── JWT Token 驗證 │
│ │ (JWT) │ │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Request Router │ │
│ │ │ │
│ │ ┌─────────────┐ ┌──────────────────┐ │ │
│ │ │ REST API │ │ GraphQL API │ │ │
│ │ │ /api/v1/* │ │ /graphql │ │ │
│ │ └──────┬──────┘ └────────┬─────────┘ │ │
│ │ │ │ │ │
│ └──────────┼─────────────────────────┼──────────────────┘ │
│ │ │ │
│ └────────────┬────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────┐ │
│ │ Conversion Service │ │
│ │ (Domain Logic) │ │
│ │ │ │
│ │ ┌─────────────────┐ │ │
│ │ │ Engine Registry │ │ │
│ │ └─────────────────┘ │ │
│ └───────────┬───────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────┐ │
│ │ External Converters │ │
│ │ (FFmpeg, etc.) │ │
│ └───────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
┌───────────────────────┐
│ File Storage │
│ ┌─────────────────┐ │
│ │ data/uploads/ │ │
│ │ data/output/ │ │
│ └─────────────────┘ │
└───────────────────────┘
```
## 模組說明
### 1. Auth Layer (`src/auth.rs`)
負責所有請求的 JWT 驗證。
**職責**
- 解析 Authorization Header
- 驗證 JWT Token 簽名
- 檢查 Token 是否過期
- 提取使用者資訊
**不負責**
- 產生 JWT Token由外部認證服務處理
- 使用者管理
### 2. REST API (`src/rest.rs`)
提供 RESTful HTTP 端點。
**端點**
- `GET /health` - 健康檢查
- `GET /api/v1/engines` - 列出引擎
- `POST /api/v1/convert` - 建立轉檔任務
- `GET /api/v1/jobs` - 列出任務
- `GET /api/v1/jobs/:id` - 取得任務狀態
- `GET /api/v1/jobs/:id/download` - 下載結果
- `DELETE /api/v1/jobs/:id` - 刪除任務
### 3. GraphQL API (`src/graphql.rs`)
提供 GraphQL 查詢與變更。
**Queries**
- `health` - 健康檢查
- `engines` - 列出引擎
- `engine(id)` - 取得特定引擎
- `jobs` - 列出任務
- `job(id)` - 取得特定任務
- `validateConversion` - 驗證轉換
- `suggestions` - 取得建議
**Mutations**
- `createJob` - 建立任務
- `deleteJob` - 刪除任務
### 4. Engine Registry (`src/engine.rs`)
管理所有可用的轉換引擎及其能力。
**職責**
- 註冊引擎及其支援的格式
- 驗證轉換是否可行
- 提供替代方案建議
**設計原則**
- 引擎必須明確指定,不自動選擇
- 不支援的轉換必須回傳建議
### 5. Conversion Service (`src/conversion.rs`)
處理轉檔任務的核心邏輯。
**職責**
- 建立和管理轉檔任務
- 儲存上傳的檔案
- 呼叫外部轉換程式
- 管理輸出檔案
### 6. Error Handling (`src/error.rs`)
統一的錯誤處理機制。
**特色**
- 結構化的錯誤回應
- 錯誤碼分類
- 轉換建議整合
## 資料流程
### 轉檔請求流程
```
Client Request
┌─────────────────┐
│ JWT Validation │ ──────► 401 Unauthorized
└────────┬────────┘
│ Valid
┌─────────────────┐
│ Parse Request │ ──────► 400 Bad Request
│ (file, engine, │
│ target_format) │
└────────┬────────┘
│ Valid
┌─────────────────┐
│ Validate │ ──────► 422 Unsupported
│ Conversion │ (with suggestions)
│ (Engine Registry)│
└────────┬────────┘
│ Supported
┌─────────────────┐
│ Create Job │
│ Save File │
│ Start Conversion│
└────────┬────────┘
┌─────────────────┐
│ 201 Created │
│ Return Job ID │
└─────────────────┘
```
### 轉換執行流程
```
Background Task
┌─────────────────┐
│ Update Status │
│ → Processing │
└────────┬────────┘
┌─────────────────┐
│ Execute │
│ Converter │
│ Command │
└────────┬────────┘
┌────┴────┐
│ │
▼ ▼
┌───────┐ ┌───────┐
│Success│ │Failed │
└───┬───┘ └───┬───┘
│ │
▼ ▼
┌───────┐ ┌───────┐
│Update │ │Update │
│Status │ │Status │
│=Done │ │=Failed│
└───────┘ └───────┘
```
## 設計決策
### 1. 為什麼使用 Rust
- **效能**:處理大量並發請求時表現優異
- **記憶體安全**:避免常見的記憶體問題
- **非同步支援**Tokio 生態系統成熟
- **類型安全**:編譯時檢查減少運行時錯誤
### 2. 為什麼同時支援 REST 和 GraphQL
- **REST**:簡單直觀,適合檔案上傳/下載
- **GraphQL**:靈活查詢,減少 over-fetching
- **獨立使用**:使用者可選擇適合的 API
### 3. 為什麼採用「結果僅透過 API 下載」策略?
- **安全性**:不暴露檔案系統路徑
- **權限控制**:下載時驗證身份
- **彈性部署**:易於整合 CDN 或雲端儲存
- **可擴展性**:未來可改用分散式儲存
### 4. 為什麼引擎必須明確指定?
- **可預測性**:使用者明確知道使用什麼工具
- **一致性**:避免因自動選擇導致的結果差異
- **透明度**:錯誤時能明確指出是哪個引擎的問題
## 擴展指南
### 新增轉換引擎
1. 在 `src/engine.rs``register_default_engines()` 中新增:
```rust
let new_engine = Engine::new(
"engine_id",
"Engine Name",
"Engine description"
)
.add_conversion("input_format", vec!["output1", "output2"]);
self.register(new_engine);
```
2. 在 `src/conversion.rs``run_converter_command()` 中新增命令:
```rust
"engine_id" => (
"command",
vec!["arg1".to_string(), input, output],
),
```
### 新增 REST 端點
`src/rest.rs` 中:
```rust
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/v1/new-endpoint", get(new_handler))
// ...
}
async fn new_handler(
State(state): State<AppState>,
RequireAuth(user): RequireAuth,
) -> ApiResult<impl IntoResponse> {
// ...
}
```
### 新增 GraphQL 查詢/變更
`src/graphql.rs` 中:
```rust
#[Object]
impl QueryRoot {
async fn new_query(&self, ctx: &Context<'_>) -> GqlResult<SomeType> {
let _user = validate_auth(ctx)?;
// ...
}
}
#[Object]
impl MutationRoot {
async fn new_mutation(&self, ctx: &Context<'_>, input: Input) -> GqlResult<Output> {
let user = validate_auth(ctx)?;
// ...
}
}
```
## 效能考量
### 並發處理
- 使用 Tokio 非同步運行時
- 轉換任務在背景執行
- 任務狀態儲存在記憶體(生產環境應使用資料庫)
### 檔案處理
- 大檔案直接寫入磁碟,不佔用過多記憶體
- 輸出檔案串流下載
### 未來優化方向
1. 使用 Redis 進行任務佇列
2. 使用 PostgreSQL 進行任務持久化
3. 實作任務優先級
4. 新增任務取消功能
5. 實作 Webhook 回呼

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

View file

@ -0,0 +1,345 @@
//! Integration tests for the ConvertX API Server
use std::sync::Arc;
use axum::http::{header, StatusCode};
use axum_test::TestServer;
use serde_json::{json, Value};
use convertx_api::{build_router, config::Config, AppState};
/// Create a test server with default configuration
fn create_test_server() -> TestServer {
let config = Config::test_config();
let state = AppState::new(config);
let app = build_router(state);
TestServer::new(app).unwrap()
}
/// Generate a valid test JWT token
fn generate_test_token() -> String {
use jsonwebtoken::{encode, EncodingKey, Header};
use chrono::Utc;
#[derive(serde::Serialize)]
struct Claims {
sub: String,
exp: i64,
iat: i64,
email: Option<String>,
roles: Vec<String>,
}
let now = Utc::now().timestamp();
let claims = Claims {
sub: "test-user-123".to_string(),
exp: now + 3600,
iat: now,
email: Some("test@example.com".to_string()),
roles: vec!["user".to_string()],
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret("test-secret-key".as_bytes()),
)
.unwrap()
}
/// Generate an expired test token
fn generate_expired_token() -> String {
use jsonwebtoken::{encode, EncodingKey, Header};
use chrono::Utc;
#[derive(serde::Serialize)]
struct Claims {
sub: String,
exp: i64,
iat: i64,
}
let now = Utc::now().timestamp();
let claims = Claims {
sub: "test-user".to_string(),
exp: now - 3600, // Expired 1 hour ago
iat: now - 7200,
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret("test-secret-key".as_bytes()),
)
.unwrap()
}
mod health_tests {
use super::*;
#[tokio::test]
async fn test_health_check() {
let server = create_test_server();
let response = server.get("/health").await;
response.assert_status_ok();
let body: Value = response.json();
assert_eq!(body["status"], "healthy");
assert!(body["version"].is_string());
assert!(body["timestamp"].is_string());
}
#[tokio::test]
async fn test_api_health_check() {
let server = create_test_server();
let response = server.get("/api/v1/health").await;
response.assert_status_ok();
}
}
mod auth_tests {
use super::*;
#[tokio::test]
async fn test_missing_auth_header() {
let server = create_test_server();
let response = server.get("/api/v1/engines").await;
response.assert_status(StatusCode::UNAUTHORIZED);
let body: Value = response.json();
assert_eq!(body["error"]["code"], "MISSING_AUTH_HEADER");
}
#[tokio::test]
async fn test_invalid_auth_scheme() {
let server = create_test_server();
let response = server
.get("/api/v1/engines")
.add_header(header::AUTHORIZATION, "Basic dXNlcjpwYXNz")
.await;
response.assert_status(StatusCode::UNAUTHORIZED);
let body: Value = response.json();
assert_eq!(body["error"]["code"], "INVALID_TOKEN");
}
#[tokio::test]
async fn test_invalid_token() {
let server = create_test_server();
let response = server
.get("/api/v1/engines")
.add_header(header::AUTHORIZATION, "Bearer invalid.token.here")
.await;
response.assert_status(StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_expired_token() {
let server = create_test_server();
let token = generate_expired_token();
let response = server
.get("/api/v1/engines")
.add_header(header::AUTHORIZATION, format!("Bearer {}", token))
.await;
response.assert_status(StatusCode::UNAUTHORIZED);
let body: Value = response.json();
assert_eq!(body["error"]["code"], "TOKEN_EXPIRED");
}
#[tokio::test]
async fn test_valid_token() {
let server = create_test_server();
let token = generate_test_token();
let response = server
.get("/api/v1/engines")
.add_header(header::AUTHORIZATION, format!("Bearer {}", token))
.await;
response.assert_status_ok();
}
}
mod engine_tests {
use super::*;
#[tokio::test]
async fn test_list_engines() {
let server = create_test_server();
let token = generate_test_token();
let response = server
.get("/api/v1/engines")
.add_header(header::AUTHORIZATION, format!("Bearer {}", token))
.await;
response.assert_status_ok();
let body: Value = response.json();
assert!(body["engines"].is_array());
let engines = body["engines"].as_array().unwrap();
assert!(!engines.is_empty());
// Check that common engines are present
let engine_ids: Vec<&str> = engines
.iter()
.filter_map(|e| e["id"].as_str())
.collect();
assert!(engine_ids.contains(&"ffmpeg"));
assert!(engine_ids.contains(&"imagemagick"));
assert!(engine_ids.contains(&"libreoffice"));
}
#[tokio::test]
async fn test_get_engine() {
let server = create_test_server();
let token = generate_test_token();
let response = server
.get("/api/v1/engines/ffmpeg")
.add_header(header::AUTHORIZATION, format!("Bearer {}", token))
.await;
response.assert_status_ok();
let body: Value = response.json();
assert_eq!(body["id"], "ffmpeg");
assert_eq!(body["name"], "FFmpeg");
assert!(body["supported_input_formats"].is_array());
assert!(body["supported_output_formats"].is_array());
}
#[tokio::test]
async fn test_get_engine_not_found() {
let server = create_test_server();
let token = generate_test_token();
let response = server
.get("/api/v1/engines/nonexistent")
.add_header(header::AUTHORIZATION, format!("Bearer {}", token))
.await;
response.assert_status(StatusCode::NOT_FOUND);
let body: Value = response.json();
assert_eq!(body["error"]["code"], "ENGINE_NOT_FOUND");
}
#[tokio::test]
async fn test_get_engine_conversions() {
let server = create_test_server();
let token = generate_test_token();
let response = server
.get("/api/v1/engines/ffmpeg/conversions")
.add_header(header::AUTHORIZATION, format!("Bearer {}", token))
.await;
response.assert_status_ok();
let body: Value = response.json();
assert_eq!(body["engine_id"], "ffmpeg");
assert!(body["conversions"].is_object());
}
}
mod job_tests {
use super::*;
#[tokio::test]
async fn test_list_jobs_empty() {
let server = create_test_server();
let token = generate_test_token();
let response = server
.get("/api/v1/jobs")
.add_header(header::AUTHORIZATION, format!("Bearer {}", token))
.await;
response.assert_status_ok();
let body: Value = response.json();
assert!(body["jobs"].is_array());
assert_eq!(body["total"], 0);
}
#[tokio::test]
async fn test_get_job_not_found() {
let server = create_test_server();
let token = generate_test_token();
let response = server
.get("/api/v1/jobs/00000000-0000-0000-0000-000000000000")
.add_header(header::AUTHORIZATION, format!("Bearer {}", token))
.await;
response.assert_status(StatusCode::NOT_FOUND);
let body: Value = response.json();
assert_eq!(body["error"]["code"], "JOB_NOT_FOUND");
}
#[tokio::test]
async fn test_create_job_missing_file() {
let server = create_test_server();
let token = generate_test_token();
// Attempt to create job without file
let response = server
.post("/api/v1/convert")
.add_header(header::AUTHORIZATION, format!("Bearer {}", token))
.content_type("multipart/form-data; boundary=boundary")
.bytes(b"--boundary\r\nContent-Disposition: form-data; name=\"engine\"\r\n\r\nffmpeg\r\n--boundary--".to_vec())
.await;
response.assert_status(StatusCode::BAD_REQUEST);
}
}
mod error_tests {
use super::*;
#[tokio::test]
async fn test_unsupported_conversion_returns_suggestions() {
// This test verifies that when an unsupported conversion is requested,
// the API returns suggestions for alternative conversions
let server = create_test_server();
let token = generate_test_token();
// Create a multipart request with an unsupported conversion
// (e.g., trying to convert PDF to MP4 using FFmpeg)
// Note: In a real test, we would create proper multipart data
// For now, we test via the GraphQL endpoint which is easier
let query = r#"{
"query": "query { validateConversion(engine: \"ffmpeg\", from: \"pdf\", to: \"mp4\") { success error { code message suggestions { engine from to } } } }"
}"#;
let response = server
.post("/graphql")
.add_header(header::AUTHORIZATION, format!("Bearer {}", token))
.content_type("application/json")
.body(query)
.await;
response.assert_status_ok();
let body: Value = response.json();
// The validateConversion query should return suggestions
if let Some(data) = body["data"]["validateConversion"].as_object() {
if data["success"] == false {
assert!(data["error"].is_object());
// Suggestions may or may not be present depending on available converters
}
}
}
}

View file

@ -0,0 +1,413 @@
//! GraphQL integration tests
use std::sync::Arc;
use axum::http::header;
use axum_test::TestServer;
use serde_json::{json, Value};
use convertx_api::{build_router, config::Config, AppState};
fn create_test_server() -> TestServer {
let config = Config::test_config();
let state = AppState::new(config);
let app = build_router(state);
TestServer::new(app).unwrap()
}
fn generate_test_token() -> String {
use jsonwebtoken::{encode, EncodingKey, Header};
use chrono::Utc;
#[derive(serde::Serialize)]
struct Claims {
sub: String,
exp: i64,
iat: i64,
}
let now = Utc::now().timestamp();
let claims = Claims {
sub: "test-user-123".to_string(),
exp: now + 3600,
iat: now,
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret("test-secret-key".as_bytes()),
)
.unwrap()
}
mod graphql_query_tests {
use super::*;
#[tokio::test]
async fn test_health_query() {
let server = create_test_server();
let query = json!({
"query": "{ health { status version timestamp } }"
});
let response = server
.post("/graphql")
.content_type("application/json")
.json(&query)
.await;
response.assert_status_ok();
let body: Value = response.json();
assert_eq!(body["data"]["health"]["status"], "healthy");
assert!(body["data"]["health"]["version"].is_string());
}
#[tokio::test]
async fn test_engines_query_unauthorized() {
let server = create_test_server();
let query = json!({
"query": "{ engines { id name description } }"
});
let response = server
.post("/graphql")
.content_type("application/json")
.json(&query)
.await;
response.assert_status_ok();
let body: Value = response.json();
// Should have errors due to missing auth
assert!(body["errors"].is_array());
}
#[tokio::test]
async fn test_engines_query() {
let server = create_test_server();
let token = generate_test_token();
let query = json!({
"query": "{ engines { id name description supportedInputFormats supportedOutputFormats } }"
});
let response = server
.post("/graphql")
.add_header(header::AUTHORIZATION, format!("Bearer {}", token))
.content_type("application/json")
.json(&query)
.await;
response.assert_status_ok();
let body: Value = response.json();
assert!(body["data"]["engines"].is_array());
let engines = body["data"]["engines"].as_array().unwrap();
assert!(!engines.is_empty());
// Verify structure
let first_engine = &engines[0];
assert!(first_engine["id"].is_string());
assert!(first_engine["name"].is_string());
assert!(first_engine["supportedInputFormats"].is_array());
assert!(first_engine["supportedOutputFormats"].is_array());
}
#[tokio::test]
async fn test_engine_query() {
let server = create_test_server();
let token = generate_test_token();
let query = json!({
"query": "{ engine(id: \"ffmpeg\") { id name description } }"
});
let response = server
.post("/graphql")
.add_header(header::AUTHORIZATION, format!("Bearer {}", token))
.content_type("application/json")
.json(&query)
.await;
response.assert_status_ok();
let body: Value = response.json();
assert_eq!(body["data"]["engine"]["id"], "ffmpeg");
assert_eq!(body["data"]["engine"]["name"], "FFmpeg");
}
#[tokio::test]
async fn test_jobs_query_empty() {
let server = create_test_server();
let token = generate_test_token();
let query = json!({
"query": "{ jobs { id status originalFilename } }"
});
let response = server
.post("/graphql")
.add_header(header::AUTHORIZATION, format!("Bearer {}", token))
.content_type("application/json")
.json(&query)
.await;
response.assert_status_ok();
let body: Value = response.json();
assert!(body["data"]["jobs"].is_array());
assert!(body["data"]["jobs"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn test_validate_conversion_supported() {
let server = create_test_server();
let token = generate_test_token();
let query = json!({
"query": "{ validateConversion(engine: \"ffmpeg\", from: \"mp4\", to: \"webm\") { success error { code message } } }"
});
let response = server
.post("/graphql")
.add_header(header::AUTHORIZATION, format!("Bearer {}", token))
.content_type("application/json")
.json(&query)
.await;
response.assert_status_ok();
let body: Value = response.json();
assert_eq!(body["data"]["validateConversion"]["success"], true);
assert!(body["data"]["validateConversion"]["error"].is_null());
}
#[tokio::test]
async fn test_validate_conversion_unsupported() {
let server = create_test_server();
let token = generate_test_token();
let query = json!({
"query": "{ validateConversion(engine: \"ffmpeg\", from: \"pdf\", to: \"mp4\") { success error { code message suggestions { engine from to } } } }"
});
let response = server
.post("/graphql")
.add_header(header::AUTHORIZATION, format!("Bearer {}", token))
.content_type("application/json")
.json(&query)
.await;
response.assert_status_ok();
let body: Value = response.json();
assert_eq!(body["data"]["validateConversion"]["success"], false);
assert!(body["data"]["validateConversion"]["error"].is_object());
assert_eq!(
body["data"]["validateConversion"]["error"]["code"],
"UNSUPPORTED_CONVERSION"
);
}
#[tokio::test]
async fn test_suggestions_query() {
let server = create_test_server();
let token = generate_test_token();
let query = json!({
"query": "{ suggestions(from: \"png\", to: \"jpg\") { engine from to } }"
});
let response = server
.post("/graphql")
.add_header(header::AUTHORIZATION, format!("Bearer {}", token))
.content_type("application/json")
.json(&query)
.await;
response.assert_status_ok();
let body: Value = response.json();
assert!(body["data"]["suggestions"].is_array());
let suggestions = body["data"]["suggestions"].as_array().unwrap();
// Should have suggestions for png to jpg conversion
assert!(!suggestions.is_empty());
// Verify at least one suggestion uses imagemagick or vips
let has_valid_engine = suggestions.iter().any(|s| {
s["engine"] == "imagemagick" || s["engine"] == "vips"
});
assert!(has_valid_engine);
}
}
mod graphql_mutation_tests {
use super::*;
use base64::{Engine as _, engine::general_purpose::STANDARD};
#[tokio::test]
async fn test_create_job_mutation() {
let server = create_test_server();
let token = generate_test_token();
// Create a simple test file content (PNG header)
let file_content = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
let file_base64 = STANDARD.encode(&file_content);
let query = json!({
"query": r#"
mutation($filename: String!, $fileBase64: String!, $input: CreateJobInput!) {
createJob(filename: $filename, fileBase64: $fileBase64, input: $input) {
success
job {
id
status
originalFilename
sourceFormat
targetFormat
engine
}
error {
code
message
suggestions {
engine
from
to
}
}
}
}
"#,
"variables": {
"filename": "test.png",
"fileBase64": file_base64,
"input": {
"engine": "imagemagick",
"targetFormat": "jpg"
}
}
});
let response = server
.post("/graphql")
.add_header(header::AUTHORIZATION, format!("Bearer {}", token))
.content_type("application/json")
.json(&query)
.await;
response.assert_status_ok();
let body: Value = response.json();
// Job creation should succeed
if body["data"]["createJob"]["success"] == true {
assert!(body["data"]["createJob"]["job"]["id"].is_string());
assert_eq!(body["data"]["createJob"]["job"]["originalFilename"], "test.png");
assert_eq!(body["data"]["createJob"]["job"]["engine"], "imagemagick");
}
}
#[tokio::test]
async fn test_create_job_unsupported_conversion() {
let server = create_test_server();
let token = generate_test_token();
// Try to convert PDF with FFmpeg (unsupported)
let file_content = b"%PDF-1.4";
let file_base64 = STANDARD.encode(file_content);
let query = json!({
"query": r#"
mutation($filename: String!, $fileBase64: String!, $input: CreateJobInput!) {
createJob(filename: $filename, fileBase64: $fileBase64, input: $input) {
success
error {
code
message
suggestions {
engine
from
to
}
}
}
}
"#,
"variables": {
"filename": "document.pdf",
"fileBase64": file_base64,
"input": {
"engine": "ffmpeg",
"targetFormat": "mp4"
}
}
});
let response = server
.post("/graphql")
.add_header(header::AUTHORIZATION, format!("Bearer {}", token))
.content_type("application/json")
.json(&query)
.await;
response.assert_status_ok();
let body: Value = response.json();
// Should fail with suggestions
assert_eq!(body["data"]["createJob"]["success"], false);
assert!(body["data"]["createJob"]["error"].is_object());
assert_eq!(body["data"]["createJob"]["error"]["code"], "UNSUPPORTED_CONVERSION");
// Should have suggestions for alternative conversions
let suggestions = &body["data"]["createJob"]["error"]["suggestions"];
assert!(suggestions.is_array());
}
#[tokio::test]
async fn test_delete_job_not_found() {
let server = create_test_server();
let token = generate_test_token();
let query = json!({
"query": r#"
mutation {
deleteJob(id: "00000000-0000-0000-0000-000000000000")
}
"#
});
let response = server
.post("/graphql")
.add_header(header::AUTHORIZATION, format!("Bearer {}", token))
.content_type("application/json")
.json(&query)
.await;
response.assert_status_ok();
let body: Value = response.json();
// Should have an error
assert!(body["errors"].is_array());
}
}
mod graphql_playground_test {
use super::*;
#[tokio::test]
async fn test_playground_available() {
let server = create_test_server();
let response = server.get("/graphql").await;
response.assert_status_ok();
let body = response.text();
// Should contain GraphQL Playground HTML
assert!(body.contains("GraphQL"));
}
}