From e083e5d11d5fc522a021c94ad1aba1e514153eb2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 21 Jan 2026 11:40:03 +0800 Subject: [PATCH] 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 --- api-server/.env.example | 19 + api-server/.gitignore | 25 ++ api-server/Cargo.toml | 63 +++ api-server/README.md | 557 ++++++++++++++++++++++++++ api-server/docs/API_SPEC.md | 614 +++++++++++++++++++++++++++++ api-server/docs/ARCHITECTURE.md | 319 +++++++++++++++ api-server/src/auth.rs | 287 ++++++++++++++ api-server/src/config.rs | 75 ++++ api-server/src/conversion.rs | 626 ++++++++++++++++++++++++++++++ api-server/src/engine.rs | 566 +++++++++++++++++++++++++++ api-server/src/error.rs | 202 ++++++++++ api-server/src/graphql.rs | 526 +++++++++++++++++++++++++ api-server/src/lib.rs | 61 +++ api-server/src/main.rs | 44 +++ api-server/src/models.rs | 215 ++++++++++ api-server/src/rest.rs | 278 +++++++++++++ api-server/tests/api_tests.rs | 345 ++++++++++++++++ api-server/tests/graphql_tests.rs | 413 ++++++++++++++++++++ 18 files changed, 5235 insertions(+) create mode 100644 api-server/.env.example create mode 100644 api-server/.gitignore create mode 100644 api-server/Cargo.toml create mode 100644 api-server/README.md create mode 100644 api-server/docs/API_SPEC.md create mode 100644 api-server/docs/ARCHITECTURE.md create mode 100644 api-server/src/auth.rs create mode 100644 api-server/src/config.rs create mode 100644 api-server/src/conversion.rs create mode 100644 api-server/src/engine.rs create mode 100644 api-server/src/error.rs create mode 100644 api-server/src/graphql.rs create mode 100644 api-server/src/lib.rs create mode 100644 api-server/src/main.rs create mode 100644 api-server/src/models.rs create mode 100644 api-server/src/rest.rs create mode 100644 api-server/tests/api_tests.rs create mode 100644 api-server/tests/graphql_tests.rs diff --git a/api-server/.env.example b/api-server/.env.example new file mode 100644 index 0000000..a0f641d --- /dev/null +++ b/api-server/.env.example @@ -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 diff --git a/api-server/.gitignore b/api-server/.gitignore new file mode 100644 index 0000000..9f92665 --- /dev/null +++ b/api-server/.gitignore @@ -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 diff --git a/api-server/Cargo.toml b/api-server/Cargo.toml new file mode 100644 index 0000000..45483de --- /dev/null +++ b/api-server/Cargo.toml @@ -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" diff --git a/api-server/README.md b/api-server/README.md new file mode 100644 index 0000000..95ad7ba --- /dev/null +++ b/api-server/README.md @@ -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 +``` + +### 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 +``` + +回應: +```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 +``` + +#### 建立轉檔任務 + +```http +POST /api/v1/convert +Authorization: Bearer +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 +``` + +#### 取得任務狀態 + +```http +GET /api/v1/jobs/:job_id +Authorization: Bearer +``` + +回應: +```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 +``` + +#### 刪除任務 + +```http +DELETE /api/v1/jobs/:job_id +Authorization: Bearer +``` + +### 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/ +│ └── / +│ └── +└── output/ + └── / + └── +``` + +使用者透過 `/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 diff --git a/api-server/docs/API_SPEC.md b/api-server/docs/API_SPEC.md new file mode 100644 index 0000000..3c243e7 --- /dev/null +++ b/api-server/docs/API_SPEC.md @@ -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 +``` + +### 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 +``` + +**回應** + +```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 +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: +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 | 內部錯誤 | ✗ | diff --git a/api-server/docs/ARCHITECTURE.md b/api-server/docs/ARCHITECTURE.md new file mode 100644 index 0000000..0aa361b --- /dev/null +++ b/api-server/docs/ARCHITECTURE.md @@ -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 { + Router::new() + .route("/api/v1/new-endpoint", get(new_handler)) + // ... +} + +async fn new_handler( + State(state): State, + RequireAuth(user): RequireAuth, +) -> ApiResult { + // ... +} +``` + +### 新增 GraphQL 查詢/變更 + +在 `src/graphql.rs` 中: + +```rust +#[Object] +impl QueryRoot { + async fn new_query(&self, ctx: &Context<'_>) -> GqlResult { + let _user = validate_auth(ctx)?; + // ... + } +} + +#[Object] +impl MutationRoot { + async fn new_mutation(&self, ctx: &Context<'_>, input: Input) -> GqlResult { + let user = validate_auth(ctx)?; + // ... + } +} +``` + +## 效能考量 + +### 並發處理 + +- 使用 Tokio 非同步運行時 +- 轉換任務在背景執行 +- 任務狀態儲存在記憶體(生產環境應使用資料庫) + +### 檔案處理 + +- 大檔案直接寫入磁碟,不佔用過多記憶體 +- 輸出檔案串流下載 + +### 未來優化方向 + +1. 使用 Redis 進行任務佇列 +2. 使用 PostgreSQL 進行任務持久化 +3. 實作任務優先級 +4. 新增任務取消功能 +5. 實作 Webhook 回呼 diff --git a/api-server/src/auth.rs b/api-server/src/auth.rs new file mode 100644 index 0000000..1d14cf0 --- /dev/null +++ b/api-server/src/auth.rs @@ -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, + /// Optional user roles + #[serde(default)] + pub roles: Vec, +} + +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::from_timestamp(self.exp, 0) + } +} + +/// Authenticated user extracted from JWT +#[derive(Debug, Clone)] +pub struct AuthenticatedUser { + pub user_id: String, + pub email: Option, + pub roles: Vec, + 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 { + let token_data = decode::(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 FromRequestParts for RequireAuth +where + S: Send + Sync, +{ + type Rejection = AuthError; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + // 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()); + } +} diff --git a/api-server/src/config.rs b/api-server/src/config.rs new file mode 100644 index 0000000..c6929a4 --- /dev/null +++ b/api-server/src/config.rs @@ -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 { + 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, + } + } +} diff --git a/api-server/src/conversion.rs b/api-server/src/conversion.rs new file mode 100644 index 0000000..a107eba --- /dev/null +++ b/api-server/src/conversion.rs @@ -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, + config: Arc, + /// In-memory job storage (in production, use a database) + jobs: RwLock>, +} + +impl ConversionService { + /// Create a new conversion service + pub fn new(engine_registry: Arc, config: Arc) -> 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, + file_data: Vec, + ) -> ApiResult { + // 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>, &'static RwLock>>(&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 { + 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 { + 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 { + 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, + config: Arc, + jobs: &'static RwLock>, +} + +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(_)))); + } +} diff --git a/api-server/src/engine.rs b/api-server/src/engine.rs new file mode 100644 index 0000000..818a573 --- /dev/null +++ b/api-server/src/engine.rs @@ -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>, +} + +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 = 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 { + self.conversions.keys().cloned().collect() + } + + /// Get all supported output formats + pub fn output_formats(&self) -> Vec { + let mut outputs: Vec = 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 { + 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, +} + +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 { + let mut engines: Vec = 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 { + 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")); + } +} diff --git a/api-server/src/error.rs b/api-server/src/error.rs new file mode 100644 index 0000000..c8af6c6 --- /dev/null +++ b/api-server/src/error.rs @@ -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>, +} + +/// 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, + }, + + #[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> { + 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 = Result; + +#[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")); + } +} diff --git a/api-server/src/graphql.rs b/api-server/src/graphql.rs new file mode 100644 index 0000000..d89805b --- /dev/null +++ b/api-server/src/graphql.rs @@ -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; + +/// Create GraphQL routes +pub fn routes() -> Router { + Router::new() + .route("/graphql", post(graphql_handler)) + .route("/graphql", get(graphql_playground)) +} + +/// GraphQL handler +async fn graphql_handler( + State(state): State, + 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, + conversion_service: Arc, + config: Arc, +) -> 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 { + let config = ctx.data::>()?; + let auth_header = ctx.data::() + .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 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, + /// Supported output formats + pub supported_output_formats: Vec, +} + +/// 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, + /// Error message (when failed) + pub error_message: Option, + /// Download URL (when completed) + pub download_url: Option, + /// Creation timestamp + pub created_at: DateTime, + /// Completion timestamp + pub completed_at: Option>, +} + +/// Conversion suggestion +#[derive(SimpleObject)] +pub struct GqlSuggestion { + /// Suggested engine + pub engine: String, + /// Source format + pub from: String, + /// Target format + pub to: String, +} + +impl From 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, +} + +/// 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, + /// Error information (if failed) + pub error: Option, +} + +/// 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, +} + +/// Health status +#[derive(SimpleObject)] +pub struct GqlHealth { + pub status: String, + pub version: String, + pub timestamp: DateTime, +} + +// ============ 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> { + let _user = validate_auth(ctx)?; + let registry = ctx.data::>()?; + + let engines: Vec = 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> { + let _user = validate_auth(ctx)?; + let registry = ctx.data::>()?; + + 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> { + let user = validate_auth(ctx)?; + let service = ctx.data::>()?; + + 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> { + let user = validate_auth(ctx)?; + let service = ctx.data::>()?; + + 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 { + let _user = validate_auth(ctx)?; + let registry = ctx.data::>()?; + + 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> { + let _user = validate_auth(ctx)?; + let registry = ctx.data::>()?; + + 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 { + let user = validate_auth(ctx)?; + let service = ctx.data::>()?; + + // 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 { + let user = validate_auth(ctx)?; + let service = ctx.data::>()?; + + 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(); + } +} diff --git a/api-server/src/lib.rs b/api-server/src/lib.rs new file mode 100644 index 0000000..ddd8189 --- /dev/null +++ b/api-server/src/lib.rs @@ -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, + pub engine_registry: Arc, + pub conversion_service: Arc, +} + +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) +} diff --git a/api-server/src/main.rs b/api-server/src/main.rs new file mode 100644 index 0000000..af376d0 --- /dev/null +++ b/api-server/src/main.rs @@ -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(()) +} diff --git a/api-server/src/models.rs b/api-server/src/models.rs new file mode 100644 index 0000000..a51f370 --- /dev/null +++ b/api-server/src/models.rs @@ -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, + /// Error message (when failed) + #[serde(skip_serializing_if = "Option::is_none")] + pub error_message: Option, + /// Job creation timestamp + pub created_at: DateTime, + /// Job completion timestamp + #[serde(skip_serializing_if = "Option::is_none")] + pub completed_at: Option>, + /// Conversion options + #[serde(skip_serializing_if = "Option::is_none")] + pub options: Option, +} + +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, + ) -> 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, +} + +/// 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, + /// Error message (when failed) + #[serde(skip_serializing_if = "Option::is_none")] + pub error_message: Option, + /// Creation time + pub created_at: DateTime, + /// Completion time + #[serde(skip_serializing_if = "Option::is_none")] + pub completed_at: Option>, +} + +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, + /// Supported output formats + pub supported_output_formats: Vec, +} + +/// List engines response +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListEnginesResponse { + pub engines: Vec, +} + +/// Health check response +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HealthResponse { + pub status: String, + pub version: String, + pub timestamp: DateTime, +} + +/// List jobs response +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListJobsResponse { + pub jobs: Vec, + pub total: usize, +} diff --git a/api-server/src/rest.rs b/api-server/src/rest.rs new file mode 100644 index 0000000..f478079 --- /dev/null +++ b/api-server/src/rest.rs @@ -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 { + 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 { + 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, + RequireAuth(_user): RequireAuth, +) -> Json { + let engines = state.engine_registry.list_info(); + Json(ListEnginesResponse { engines }) +} + +/// Get a specific engine's details +async fn get_engine( + State(state): State, + RequireAuth(_user): RequireAuth, + Path(engine_id): Path, +) -> ApiResult { + 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, + RequireAuth(_user): RequireAuth, + Path(engine_id): Path, +) -> ApiResult { + 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, + RequireAuth(user): RequireAuth, + mut multipart: Multipart, +) -> ApiResult { + let mut file_data: Option> = None; + let mut filename: Option = None; + let mut engine: Option = None; + let mut target_format: Option = None; + let mut options: Option = 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, + RequireAuth(user): RequireAuth, +) -> Json { + let jobs = state.conversion_service.get_user_jobs(&user.user_id).await; + let job_responses: Vec = 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, + RequireAuth(user): RequireAuth, + Path(job_id): Path, +) -> ApiResult> { + 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, + RequireAuth(user): RequireAuth, + Path(job_id): Path, +) -> ApiResult { + 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, + RequireAuth(user): RequireAuth, + Path(job_id): Path, +) -> ApiResult { + 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(); + } +} diff --git a/api-server/tests/api_tests.rs b/api-server/tests/api_tests.rs new file mode 100644 index 0000000..a1cf2b0 --- /dev/null +++ b/api-server/tests/api_tests.rs @@ -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, + roles: Vec, + } + + 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 + } + } + } +} diff --git a/api-server/tests/graphql_tests.rs b/api-server/tests/graphql_tests.rs new file mode 100644 index 0000000..6d54357 --- /dev/null +++ b/api-server/tests/graphql_tests.rs @@ -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")); + } +}