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