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