feat(api): Docker Compose 整合與補充文件 - Docker Compose profiles 支援選用 API Server - API Server Dockerfile (多階段建置) - .env.api.example 環境變數範本 - integration_tests.rs 完整整合測試 - health_check.sh 健康檢查腳本 - 主專案 README 新增 API Server 說明區塊
This commit is contained in:
parent
e083e5d11d
commit
e577658231
9 changed files with 1269 additions and 144 deletions
35
README.md
35
README.md
|
|
@ -194,6 +194,41 @@ docker compose up -d
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## API Server(選用)
|
||||||
|
|
||||||
|
如需以程式整合方式使用 ConvertX 的轉檔功能,可啟用 **API Server**。
|
||||||
|
|
||||||
|
> ⚠️ API Server 為**選用功能**,不影響現有 Web UI 的使用。若只使用網頁介面,無需任何額外設定。
|
||||||
|
|
||||||
|
### 功能特點
|
||||||
|
|
||||||
|
- 🔐 **JWT 認證** — 安全的 API 存取控制
|
||||||
|
- 🌐 **REST + GraphQL** — 雙協議支援,滿足不同整合需求
|
||||||
|
- 🔍 **智慧建議** — 轉換失敗時自動推薦替代引擎
|
||||||
|
- 🛠️ **20+ 轉換引擎** — 與 Web UI 共用完整轉換器套件
|
||||||
|
|
||||||
|
### 快速啟用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 同時啟動 Web UI 與 API Server
|
||||||
|
docker compose --profile api up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
| 服務 | 端口 | 說明 |
|
||||||
|
| ---------- | ---- | ----------------- |
|
||||||
|
| Web UI | 3000 | 網頁介面 |
|
||||||
|
| API Server | 3001 | REST & GraphQL |
|
||||||
|
|
||||||
|
### API 文件
|
||||||
|
|
||||||
|
詳細的 API 規格與使用說明:
|
||||||
|
|
||||||
|
- 📘 [API Server README](api-server/README.md)
|
||||||
|
- 📗 [API 規格文件](api-server/docs/API_SPEC.md)
|
||||||
|
- 📙 [架構說明](api-server/docs/ARCHITECTURE.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 進階文件
|
## 進階文件
|
||||||
|
|
||||||
| 文件 | 說明 |
|
| 文件 | 說明 |
|
||||||
|
|
|
||||||
73
api-server/.env.api.example
Normal file
73
api-server/.env.api.example
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
# ==============================================================================
|
||||||
|
# ConvertX-CN API Server 環境變數設定
|
||||||
|
# ==============================================================================
|
||||||
|
#
|
||||||
|
# 📌 使用方式:
|
||||||
|
# 1. 複製此檔案到專案根目錄:
|
||||||
|
# cp api-server/.env.api.example .env.api
|
||||||
|
#
|
||||||
|
# 2. 修改設定值(特別是 JWT_SECRET)
|
||||||
|
#
|
||||||
|
# 3. 啟動 API Server:
|
||||||
|
# docker compose --profile api up -d
|
||||||
|
#
|
||||||
|
# ⚠️ 此檔案僅供 API Server 使用,Web UI 不會讀取這些設定
|
||||||
|
#
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 伺服器設定
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# API 監聽地址
|
||||||
|
API_HOST=0.0.0.0
|
||||||
|
|
||||||
|
# API 監聽埠
|
||||||
|
API_PORT=3001
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# JWT 認證設定(🔐 必須修改)
|
||||||
|
# ==============================================================================
|
||||||
|
#
|
||||||
|
# JWT 密鑰用於驗證 API 請求的 Bearer Token
|
||||||
|
#
|
||||||
|
# ⚠️ 重要:
|
||||||
|
# - 必須與產生 Token 的認證服務使用相同的密鑰
|
||||||
|
# - 請使用長度至少 32 字元的隨機字串
|
||||||
|
# - 可用 openssl rand -hex 32 產生
|
||||||
|
#
|
||||||
|
API_JWT_SECRET=請改成你自己的長隨機字串-至少32個字元-不要用這個預設值
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 檔案儲存設定
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# 上傳檔案儲存目錄
|
||||||
|
# 容器內路徑,會映射到 ./data/api-uploads
|
||||||
|
UPLOAD_DIR=/app/data/api-uploads
|
||||||
|
|
||||||
|
# 轉換結果儲存目錄
|
||||||
|
# 容器內路徑,會映射到 ./data/api-output
|
||||||
|
OUTPUT_DIR=/app/data/api-output
|
||||||
|
|
||||||
|
# 最大上傳檔案大小(bytes)
|
||||||
|
# 預設 100MB = 104857600
|
||||||
|
MAX_FILE_SIZE=104857600
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 日誌設定
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# Rust 日誌級別
|
||||||
|
# 可用值: error, warn, info, debug, trace
|
||||||
|
# 格式: module=level,module=level
|
||||||
|
RUST_LOG=convertx_api=info,tower_http=info
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 進階設定(可選)
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# JWT Token 過期時間參考(秒)
|
||||||
|
# 注意:API Server 只驗證 Token,不產生 Token
|
||||||
|
# 這個值僅供參考,實際過期時間由 Token 本身的 exp 欄位決定
|
||||||
|
# JWT_EXPIRATION_SECS=86400
|
||||||
101
api-server/Dockerfile
Normal file
101
api-server/Dockerfile
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# ==============================================================================
|
||||||
|
# ConvertX-CN API Server Dockerfile
|
||||||
|
# ==============================================================================
|
||||||
|
#
|
||||||
|
# 📦 說明:
|
||||||
|
# - 這是 ConvertX-CN API Server 的獨立 Dockerfile
|
||||||
|
# - 使用 Rust 編譯,提供 REST 與 GraphQL API
|
||||||
|
# - 此 API Server 獨立於 Web UI,直接調用轉換引擎
|
||||||
|
#
|
||||||
|
# 🔧 建置方式:
|
||||||
|
# docker build -f api-server/Dockerfile -t convertx-api .
|
||||||
|
#
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# Stage 1: Build
|
||||||
|
FROM rust:1.75-bookworm AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
pkg-config \
|
||||||
|
libssl-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy only Cargo files first for dependency caching
|
||||||
|
COPY Cargo.toml Cargo.lock* ./
|
||||||
|
|
||||||
|
# Create a dummy main.rs to build dependencies
|
||||||
|
RUN mkdir src && \
|
||||||
|
echo "fn main() {}" > src/main.rs && \
|
||||||
|
cargo build --release && \
|
||||||
|
rm -rf src
|
||||||
|
|
||||||
|
# Copy actual source code
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
# Build the actual application
|
||||||
|
RUN touch src/main.rs && cargo build --release
|
||||||
|
|
||||||
|
# Stage 2: Runtime
|
||||||
|
FROM debian:bookworm-slim AS runtime
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install runtime dependencies and conversion tools
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
# SSL/TLS support
|
||||||
|
ca-certificates \
|
||||||
|
libssl3 \
|
||||||
|
# Conversion tools (subset - main ones used by API)
|
||||||
|
ffmpeg \
|
||||||
|
imagemagick-7.q16 \
|
||||||
|
graphicsmagick \
|
||||||
|
libreoffice \
|
||||||
|
pandoc \
|
||||||
|
inkscape \
|
||||||
|
potrace \
|
||||||
|
libvips-tools \
|
||||||
|
libheif-examples \
|
||||||
|
libjxl-tools \
|
||||||
|
resvg \
|
||||||
|
dasel \
|
||||||
|
assimp-utils \
|
||||||
|
calibre \
|
||||||
|
ghostscript \
|
||||||
|
# Python for markitdown
|
||||||
|
python3 \
|
||||||
|
python3-pip \
|
||||||
|
pipx \
|
||||||
|
&& pipx install "markitdown[all]" \
|
||||||
|
# Cleanup
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& rm -rf /root/.cache/pip
|
||||||
|
|
||||||
|
# Add pipx bin directory to PATH
|
||||||
|
ENV PATH="/root/.local/bin:${PATH}"
|
||||||
|
|
||||||
|
# Copy the compiled binary
|
||||||
|
COPY --from=builder /app/target/release/convertx-api /usr/local/bin/convertx-api
|
||||||
|
|
||||||
|
# Create data directories
|
||||||
|
RUN mkdir -p /app/data/uploads /app/data/output
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV API_HOST=0.0.0.0
|
||||||
|
ENV API_PORT=3001
|
||||||
|
ENV UPLOAD_DIR=/app/data/uploads
|
||||||
|
ENV OUTPUT_DIR=/app/data/output
|
||||||
|
ENV RUST_LOG=convertx_api=info,tower_http=info
|
||||||
|
|
||||||
|
# Expose API port
|
||||||
|
EXPOSE 3001/tcp
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:3001/health || exit 1
|
||||||
|
|
||||||
|
# Run the API server
|
||||||
|
ENTRYPOINT ["/usr/local/bin/convertx-api"]
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# ConvertX API Server
|
# ConvertX-CN API Server
|
||||||
|
|
||||||
一個使用 Rust 實作的 REST 與 GraphQL 檔案轉換 API 伺服器。
|
一個使用 Rust 實作的 REST 與 GraphQL 檔案轉換 API 伺服器。
|
||||||
|
|
||||||
|
|
@ -54,14 +54,14 @@ cargo run --release
|
||||||
|
|
||||||
### 環境變數
|
### 環境變數
|
||||||
|
|
||||||
| 變數 | 說明 | 預設值 |
|
| 變數 | 說明 | 預設值 |
|
||||||
|------|------|--------|
|
| --------------- | --------------------- | ------------------------ |
|
||||||
| `API_HOST` | 伺服器監聽地址 | `0.0.0.0` |
|
| `API_HOST` | 伺服器監聽地址 | `0.0.0.0` |
|
||||||
| `API_PORT` | 伺服器監聽埠 | `3001` |
|
| `API_PORT` | 伺服器監聽埠 | `3001` |
|
||||||
| `JWT_SECRET` | JWT 驗證密鑰 | (預設值,正式環境請更改) |
|
| `JWT_SECRET` | JWT 驗證密鑰 | (預設值,正式環境請更改) |
|
||||||
| `UPLOAD_DIR` | 上傳檔案目錄 | `./data/uploads` |
|
| `UPLOAD_DIR` | 上傳檔案目錄 | `./data/uploads` |
|
||||||
| `OUTPUT_DIR` | 輸出檔案目錄 | `./data/output` |
|
| `OUTPUT_DIR` | 輸出檔案目錄 | `./data/output` |
|
||||||
| `MAX_FILE_SIZE` | 最大檔案大小(bytes) | `104857600` (100MB) |
|
| `MAX_FILE_SIZE` | 最大檔案大小(bytes) | `104857600` (100MB) |
|
||||||
|
|
||||||
### 範例 .env 檔案
|
### 範例 .env 檔案
|
||||||
|
|
||||||
|
|
@ -114,6 +114,7 @@ GET /api/v1/health
|
||||||
```
|
```
|
||||||
|
|
||||||
回應:
|
回應:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
|
|
@ -130,6 +131,7 @@ Authorization: Bearer <token>
|
||||||
```
|
```
|
||||||
|
|
||||||
回應:
|
回應:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"engines": [
|
"engines": [
|
||||||
|
|
@ -160,12 +162,14 @@ Content-Type: multipart/form-data
|
||||||
```
|
```
|
||||||
|
|
||||||
表單欄位:
|
表單欄位:
|
||||||
|
|
||||||
- `file`: 要轉換的檔案(必填)
|
- `file`: 要轉換的檔案(必填)
|
||||||
- `engine`: 轉換引擎 ID(必填)
|
- `engine`: 轉換引擎 ID(必填)
|
||||||
- `target_format`: 目標格式(必填)
|
- `target_format`: 目標格式(必填)
|
||||||
- `options`: JSON 格式的選項(選填)
|
- `options`: JSON 格式的選項(選填)
|
||||||
|
|
||||||
回應:
|
回應:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"job_id": "550e8400-e29b-41d4-a716-446655440000",
|
"job_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
|
@ -189,6 +193,7 @@ Authorization: Bearer <token>
|
||||||
```
|
```
|
||||||
|
|
||||||
回應:
|
回應:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"job_id": "550e8400-e29b-41d4-a716-446655440000",
|
"job_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
|
@ -262,22 +267,22 @@ GraphQL Playground 可透過瀏覽器訪問 `http://localhost:3001/graphql`
|
||||||
type Query {
|
type Query {
|
||||||
# 健康檢查(不需認證)
|
# 健康檢查(不需認證)
|
||||||
health: Health!
|
health: Health!
|
||||||
|
|
||||||
# 列出所有引擎
|
# 列出所有引擎
|
||||||
engines: [Engine!]!
|
engines: [Engine!]!
|
||||||
|
|
||||||
# 取得特定引擎
|
# 取得特定引擎
|
||||||
engine(id: ID!): Engine
|
engine(id: ID!): Engine
|
||||||
|
|
||||||
# 列出使用者的任務
|
# 列出使用者的任務
|
||||||
jobs: [Job!]!
|
jobs: [Job!]!
|
||||||
|
|
||||||
# 取得特定任務
|
# 取得特定任務
|
||||||
job(id: ID!): Job
|
job(id: ID!): Job
|
||||||
|
|
||||||
# 驗證轉換是否支援
|
# 驗證轉換是否支援
|
||||||
validateConversion(engine: String!, from: String!, to: String!): CreateJobResult!
|
validateConversion(engine: String!, from: String!, to: String!): CreateJobResult!
|
||||||
|
|
||||||
# 取得轉換建議
|
# 取得轉換建議
|
||||||
suggestions(from: String!, to: String!): [Suggestion!]!
|
suggestions(from: String!, to: String!): [Suggestion!]!
|
||||||
}
|
}
|
||||||
|
|
@ -288,12 +293,8 @@ type Query {
|
||||||
```graphql
|
```graphql
|
||||||
type Mutation {
|
type Mutation {
|
||||||
# 建立轉檔任務
|
# 建立轉檔任務
|
||||||
createJob(
|
createJob(filename: String!, fileBase64: String!, input: CreateJobInput!): CreateJobResult!
|
||||||
filename: String!
|
|
||||||
fileBase64: String!
|
|
||||||
input: CreateJobInput!
|
|
||||||
): CreateJobResult!
|
|
||||||
|
|
||||||
# 刪除任務
|
# 刪除任務
|
||||||
deleteJob(id: ID!): Boolean!
|
deleteJob(id: ID!): Boolean!
|
||||||
}
|
}
|
||||||
|
|
@ -390,10 +391,7 @@ mutation {
|
||||||
createJob(
|
createJob(
|
||||||
filename: "video.mp4"
|
filename: "video.mp4"
|
||||||
fileBase64: "base64-encoded-content"
|
fileBase64: "base64-encoded-content"
|
||||||
input: {
|
input: { engine: "ffmpeg", targetFormat: "webm" }
|
||||||
engine: "ffmpeg"
|
|
||||||
targetFormat: "webm"
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
success
|
success
|
||||||
job {
|
job {
|
||||||
|
|
@ -450,21 +448,21 @@ query {
|
||||||
|
|
||||||
### 錯誤碼
|
### 錯誤碼
|
||||||
|
|
||||||
| 錯誤碼 | HTTP 狀態 | 說明 |
|
| 錯誤碼 | HTTP 狀態 | 說明 |
|
||||||
|--------|-----------|------|
|
| ------------------------ | --------- | ------------------------ |
|
||||||
| `UNAUTHORIZED` | 401 | 未授權 |
|
| `UNAUTHORIZED` | 401 | 未授權 |
|
||||||
| `INVALID_TOKEN` | 401 | Token 格式或簽名無效 |
|
| `INVALID_TOKEN` | 401 | Token 格式或簽名無效 |
|
||||||
| `TOKEN_EXPIRED` | 401 | Token 已過期 |
|
| `TOKEN_EXPIRED` | 401 | Token 已過期 |
|
||||||
| `MISSING_AUTH_HEADER` | 401 | 缺少 Authorization 標頭 |
|
| `MISSING_AUTH_HEADER` | 401 | 缺少 Authorization 標頭 |
|
||||||
| `BAD_REQUEST` | 400 | 請求格式錯誤 |
|
| `BAD_REQUEST` | 400 | 請求格式錯誤 |
|
||||||
| `INVALID_FILE` | 400 | 檔案格式無法辨識 |
|
| `INVALID_FILE` | 400 | 檔案格式無法辨識 |
|
||||||
| `FILE_TOO_LARGE` | 400 | 檔案超過大小限制 |
|
| `FILE_TOO_LARGE` | 400 | 檔案超過大小限制 |
|
||||||
| `ENGINE_NOT_FOUND` | 404 | 指定的引擎不存在 |
|
| `ENGINE_NOT_FOUND` | 404 | 指定的引擎不存在 |
|
||||||
| `JOB_NOT_FOUND` | 404 | 任務不存在 |
|
| `JOB_NOT_FOUND` | 404 | 任務不存在 |
|
||||||
| `FILE_NOT_FOUND` | 404 | 檔案不存在 |
|
| `FILE_NOT_FOUND` | 404 | 檔案不存在 |
|
||||||
| `UNSUPPORTED_CONVERSION` | 422 | 不支援的轉換(附帶建議) |
|
| `UNSUPPORTED_CONVERSION` | 422 | 不支援的轉換(附帶建議) |
|
||||||
| `CONVERSION_FAILED` | 500 | 轉換過程失敗 |
|
| `CONVERSION_FAILED` | 500 | 轉換過程失敗 |
|
||||||
| `INTERNAL_ERROR` | 500 | 內部錯誤 |
|
| `INTERNAL_ERROR` | 500 | 內部錯誤 |
|
||||||
|
|
||||||
## 📦 轉換結果策略
|
## 📦 轉換結果策略
|
||||||
|
|
||||||
|
|
@ -480,6 +478,7 @@ query {
|
||||||
### 檔案儲存
|
### 檔案儲存
|
||||||
|
|
||||||
內部儲存結構:
|
內部儲存結構:
|
||||||
|
|
||||||
```
|
```
|
||||||
data/
|
data/
|
||||||
├── uploads/
|
├── uploads/
|
||||||
|
|
@ -494,28 +493,28 @@ data/
|
||||||
|
|
||||||
## 🔧 支援的轉換引擎
|
## 🔧 支援的轉換引擎
|
||||||
|
|
||||||
| 引擎 ID | 名稱 | 說明 |
|
| 引擎 ID | 名稱 | 說明 |
|
||||||
|---------|------|------|
|
| ---------------- | -------------- | ------------------------ |
|
||||||
| `ffmpeg` | FFmpeg | 音視頻轉換 |
|
| `ffmpeg` | FFmpeg | 音視頻轉換 |
|
||||||
| `imagemagick` | ImageMagick | 圖片格式轉換 |
|
| `imagemagick` | ImageMagick | 圖片格式轉換 |
|
||||||
| `graphicsmagick` | GraphicsMagick | 圖片格式轉換(替代方案) |
|
| `graphicsmagick` | GraphicsMagick | 圖片格式轉換(替代方案) |
|
||||||
| `libreoffice` | LibreOffice | 辦公文件轉換 |
|
| `libreoffice` | LibreOffice | 辦公文件轉換 |
|
||||||
| `pandoc` | Pandoc | 文件/標記語言轉換 |
|
| `pandoc` | Pandoc | 文件/標記語言轉換 |
|
||||||
| `calibre` | Calibre | 電子書轉換 |
|
| `calibre` | Calibre | 電子書轉換 |
|
||||||
| `inkscape` | Inkscape | 向量圖轉換 |
|
| `inkscape` | Inkscape | 向量圖轉換 |
|
||||||
| `resvg` | resvg | SVG 渲染 |
|
| `resvg` | resvg | SVG 渲染 |
|
||||||
| `vips` | libvips | 高效能圖片處理 |
|
| `vips` | libvips | 高效能圖片處理 |
|
||||||
| `libheif` | libheif | HEIF/HEIC 轉換 |
|
| `libheif` | libheif | HEIF/HEIC 轉換 |
|
||||||
| `libjxl` | libjxl | JPEG XL 轉換 |
|
| `libjxl` | libjxl | JPEG XL 轉換 |
|
||||||
| `potrace` | Potrace | 點陣圖轉向量 |
|
| `potrace` | Potrace | 點陣圖轉向量 |
|
||||||
| `vtracer` | VTracer | 進階向量化 |
|
| `vtracer` | VTracer | 進階向量化 |
|
||||||
| `dasel` | Dasel | 資料格式轉換 |
|
| `dasel` | Dasel | 資料格式轉換 |
|
||||||
| `assimp` | Assimp | 3D 模型轉換 |
|
| `assimp` | Assimp | 3D 模型轉換 |
|
||||||
| `xelatex` | XeLaTeX | LaTeX 編譯 |
|
| `xelatex` | XeLaTeX | LaTeX 編譯 |
|
||||||
| `dvisvgm` | dvisvgm | DVI 轉 SVG |
|
| `dvisvgm` | dvisvgm | DVI 轉 SVG |
|
||||||
| `msgconvert` | msgconvert | Outlook MSG 轉 EML |
|
| `msgconvert` | msgconvert | Outlook MSG 轉 EML |
|
||||||
| `vcf` | VCF Converter | vCard 轉換 |
|
| `vcf` | VCF Converter | vCard 轉換 |
|
||||||
| `markitdown` | MarkItDown | 文件轉 Markdown |
|
| `markitdown` | MarkItDown | 文件轉 Markdown |
|
||||||
|
|
||||||
## 🧪 測試
|
## 🧪 測試
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,22 +27,22 @@ Authorization: Bearer <jwt-token>
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| 欄位 | 必填 | 說明 |
|
| 欄位 | 必填 | 說明 |
|
||||||
|------|------|------|
|
| ------- | ---- | -------------------------------- |
|
||||||
| `sub` | ✓ | 使用者唯一識別碼 |
|
| `sub` | ✓ | 使用者唯一識別碼 |
|
||||||
| `exp` | ✓ | Token 過期時間(Unix timestamp) |
|
| `exp` | ✓ | Token 過期時間(Unix timestamp) |
|
||||||
| `iat` | ✓ | Token 簽發時間(Unix timestamp) |
|
| `iat` | ✓ | Token 簽發時間(Unix timestamp) |
|
||||||
| `email` | - | 使用者 Email |
|
| `email` | - | 使用者 Email |
|
||||||
| `roles` | - | 使用者角色列表 |
|
| `roles` | - | 使用者角色列表 |
|
||||||
|
|
||||||
### 認證錯誤回應
|
### 認證錯誤回應
|
||||||
|
|
||||||
| 狀況 | 錯誤碼 | HTTP 狀態 |
|
| 狀況 | 錯誤碼 | HTTP 狀態 |
|
||||||
|------|--------|-----------|
|
| ------------------------- | --------------------- | --------- |
|
||||||
| 缺少 Authorization Header | `MISSING_AUTH_HEADER` | 401 |
|
| 缺少 Authorization Header | `MISSING_AUTH_HEADER` | 401 |
|
||||||
| Token 格式錯誤 | `INVALID_TOKEN` | 401 |
|
| Token 格式錯誤 | `INVALID_TOKEN` | 401 |
|
||||||
| Token 簽名無效 | `INVALID_TOKEN` | 401 |
|
| Token 簽名無效 | `INVALID_TOKEN` | 401 |
|
||||||
| Token 已過期 | `TOKEN_EXPIRED` | 401 |
|
| Token 已過期 | `TOKEN_EXPIRED` | 401 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -80,6 +80,7 @@ Authorization: Bearer <jwt-token>
|
||||||
列出所有可用的轉換引擎。
|
列出所有可用的轉換引擎。
|
||||||
|
|
||||||
**Headers**
|
**Headers**
|
||||||
|
|
||||||
```
|
```
|
||||||
Authorization: Bearer <token>
|
Authorization: Bearer <token>
|
||||||
```
|
```
|
||||||
|
|
@ -93,8 +94,33 @@ Authorization: Bearer <token>
|
||||||
"id": "ffmpeg",
|
"id": "ffmpeg",
|
||||||
"name": "FFmpeg",
|
"name": "FFmpeg",
|
||||||
"description": "Audio and video conversion using FFmpeg",
|
"description": "Audio and video conversion using FFmpeg",
|
||||||
"supported_input_formats": ["mp4", "webm", "avi", "mkv", "mov", "mp3", "wav", "flac", "ogg", "m4a", "gif"],
|
"supported_input_formats": [
|
||||||
"supported_output_formats": ["webm", "avi", "mkv", "mov", "mp3", "wav", "flac", "ogg", "gif", "m4a", "aac", "mp4"]
|
"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",
|
"id": "imagemagick",
|
||||||
|
|
@ -114,6 +140,7 @@ Authorization: Bearer <token>
|
||||||
取得特定引擎的詳細資訊。
|
取得特定引擎的詳細資訊。
|
||||||
|
|
||||||
**參數**
|
**參數**
|
||||||
|
|
||||||
- `engine_id`: 引擎識別碼(如 `ffmpeg`, `imagemagick`)
|
- `engine_id`: 引擎識別碼(如 `ffmpeg`, `imagemagick`)
|
||||||
|
|
||||||
**回應 (200)**
|
**回應 (200)**
|
||||||
|
|
@ -167,6 +194,7 @@ Authorization: Bearer <token>
|
||||||
建立新的轉檔任務。
|
建立新的轉檔任務。
|
||||||
|
|
||||||
**Headers**
|
**Headers**
|
||||||
|
|
||||||
```
|
```
|
||||||
Authorization: Bearer <token>
|
Authorization: Bearer <token>
|
||||||
Content-Type: multipart/form-data
|
Content-Type: multipart/form-data
|
||||||
|
|
@ -174,12 +202,12 @@ Content-Type: multipart/form-data
|
||||||
|
|
||||||
**表單欄位**
|
**表單欄位**
|
||||||
|
|
||||||
| 欄位 | 類型 | 必填 | 說明 |
|
| 欄位 | 類型 | 必填 | 說明 |
|
||||||
|------|------|------|------|
|
| --------------- | ------ | ---- | ------------------- |
|
||||||
| `file` | File | ✓ | 要轉換的檔案 |
|
| `file` | File | ✓ | 要轉換的檔案 |
|
||||||
| `engine` | String | ✓ | 轉換引擎 ID |
|
| `engine` | String | ✓ | 轉換引擎 ID |
|
||||||
| `target_format` | String | ✓ | 目標格式(不含點) |
|
| `target_format` | String | ✓ | 目標格式(不含點) |
|
||||||
| `options` | String | - | JSON 格式的轉換選項 |
|
| `options` | String | - | JSON 格式的轉換選項 |
|
||||||
|
|
||||||
**回應 (201)**
|
**回應 (201)**
|
||||||
|
|
||||||
|
|
@ -254,12 +282,12 @@ Content-Type: multipart/form-data
|
||||||
|
|
||||||
**任務狀態**
|
**任務狀態**
|
||||||
|
|
||||||
| 狀態 | 說明 |
|
| 狀態 | 說明 |
|
||||||
|------|------|
|
| ------------ | -------- |
|
||||||
| `pending` | 等待處理 |
|
| `pending` | 等待處理 |
|
||||||
| `processing` | 處理中 |
|
| `processing` | 處理中 |
|
||||||
| `completed` | 完成 |
|
| `completed` | 完成 |
|
||||||
| `failed` | 失敗 |
|
| `failed` | 失敗 |
|
||||||
|
|
||||||
**回應 (200) - 完成**
|
**回應 (200) - 完成**
|
||||||
|
|
||||||
|
|
@ -300,10 +328,12 @@ Content-Type: multipart/form-data
|
||||||
下載轉換後的檔案。
|
下載轉換後的檔案。
|
||||||
|
|
||||||
**前提條件**
|
**前提條件**
|
||||||
|
|
||||||
- 任務狀態必須是 `completed`
|
- 任務狀態必須是 `completed`
|
||||||
- 只有任務建立者可以下載
|
- 只有任務建立者可以下載
|
||||||
|
|
||||||
**回應 Headers**
|
**回應 Headers**
|
||||||
|
|
||||||
```
|
```
|
||||||
Content-Type: <mime-type>
|
Content-Type: <mime-type>
|
||||||
Content-Disposition: attachment; filename="output.webm"
|
Content-Disposition: attachment; filename="output.webm"
|
||||||
|
|
@ -311,12 +341,12 @@ Content-Disposition: attachment; filename="output.webm"
|
||||||
|
|
||||||
**錯誤回應**
|
**錯誤回應**
|
||||||
|
|
||||||
| 狀況 | 錯誤碼 | HTTP 狀態 |
|
| 狀況 | 錯誤碼 | HTTP 狀態 |
|
||||||
|------|--------|-----------|
|
| ---------- | ---------------- | --------- |
|
||||||
| 任務未完成 | `BAD_REQUEST` | 400 |
|
| 任務未完成 | `BAD_REQUEST` | 400 |
|
||||||
| 無權限 | `UNAUTHORIZED` | 401 |
|
| 無權限 | `UNAUTHORIZED` | 401 |
|
||||||
| 任務不存在 | `JOB_NOT_FOUND` | 404 |
|
| 任務不存在 | `JOB_NOT_FOUND` | 404 |
|
||||||
| 檔案遺失 | `FILE_NOT_FOUND` | 404 |
|
| 檔案遺失 | `FILE_NOT_FOUND` | 404 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -351,56 +381,72 @@ GraphQL Playground 可透過 GET 請求訪問同一 URL。
|
||||||
# ========== Queries ==========
|
# ========== Queries ==========
|
||||||
|
|
||||||
type Query {
|
type Query {
|
||||||
"""健康檢查(不需認證)"""
|
"""
|
||||||
|
健康檢查(不需認證)
|
||||||
|
"""
|
||||||
health: Health!
|
health: Health!
|
||||||
|
|
||||||
"""列出所有可用引擎"""
|
"""
|
||||||
|
列出所有可用引擎
|
||||||
|
"""
|
||||||
engines: [Engine!]!
|
engines: [Engine!]!
|
||||||
|
|
||||||
"""取得特定引擎"""
|
"""
|
||||||
|
取得特定引擎
|
||||||
|
"""
|
||||||
engine(id: ID!): Engine
|
engine(id: ID!): Engine
|
||||||
|
|
||||||
"""列出當前使用者的所有任務"""
|
"""
|
||||||
|
列出當前使用者的所有任務
|
||||||
|
"""
|
||||||
jobs: [Job!]!
|
jobs: [Job!]!
|
||||||
|
|
||||||
"""取得特定任務"""
|
"""
|
||||||
|
取得特定任務
|
||||||
|
"""
|
||||||
job(id: ID!): Job
|
job(id: ID!): Job
|
||||||
|
|
||||||
"""驗證轉換是否支援"""
|
"""
|
||||||
validateConversion(
|
驗證轉換是否支援
|
||||||
engine: String!
|
"""
|
||||||
from: String!
|
validateConversion(engine: String!, from: String!, to: String!): CreateJobResult!
|
||||||
to: String!
|
|
||||||
): CreateJobResult!
|
"""
|
||||||
|
取得轉換建議
|
||||||
"""取得轉換建議"""
|
"""
|
||||||
suggestions(from: String!, to: String!): [Suggestion!]!
|
suggestions(from: String!, to: String!): [Suggestion!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
# ========== Mutations ==========
|
# ========== Mutations ==========
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
"""建立轉檔任務"""
|
"""
|
||||||
createJob(
|
建立轉檔任務
|
||||||
filename: String!
|
"""
|
||||||
fileBase64: String!
|
createJob(filename: String!, fileBase64: String!, input: CreateJobInput!): CreateJobResult!
|
||||||
input: CreateJobInput!
|
|
||||||
): CreateJobResult!
|
"""
|
||||||
|
刪除任務
|
||||||
"""刪除任務"""
|
"""
|
||||||
deleteJob(id: ID!): Boolean!
|
deleteJob(id: ID!): Boolean!
|
||||||
}
|
}
|
||||||
|
|
||||||
# ========== Input Types ==========
|
# ========== Input Types ==========
|
||||||
|
|
||||||
input CreateJobInput {
|
input CreateJobInput {
|
||||||
"""轉換引擎 ID"""
|
"""
|
||||||
|
轉換引擎 ID
|
||||||
|
"""
|
||||||
engine: String!
|
engine: String!
|
||||||
|
|
||||||
"""目標格式"""
|
"""
|
||||||
|
目標格式
|
||||||
|
"""
|
||||||
targetFormat: String!
|
targetFormat: String!
|
||||||
|
|
||||||
"""轉換選項(JSON 字串)"""
|
"""
|
||||||
|
轉換選項(JSON 字串)
|
||||||
|
"""
|
||||||
options: String
|
options: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -520,10 +566,7 @@ mutation CreateConversionJob($filename: String!, $content: String!) {
|
||||||
createJob(
|
createJob(
|
||||||
filename: $filename
|
filename: $filename
|
||||||
fileBase64: $content
|
fileBase64: $content
|
||||||
input: {
|
input: { engine: "imagemagick", targetFormat: "jpg" }
|
||||||
engine: "imagemagick"
|
|
||||||
targetFormat: "jpg"
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
success
|
success
|
||||||
job {
|
job {
|
||||||
|
|
@ -545,6 +588,7 @@ mutation CreateConversionJob($filename: String!, $content: String!) {
|
||||||
```
|
```
|
||||||
|
|
||||||
Variables:
|
Variables:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"filename": "image.png",
|
"filename": "image.png",
|
||||||
|
|
@ -597,18 +641,18 @@ mutation {
|
||||||
|
|
||||||
## 錯誤碼對照表
|
## 錯誤碼對照表
|
||||||
|
|
||||||
| 錯誤碼 | HTTP 狀態 | 說明 | 包含建議 |
|
| 錯誤碼 | HTTP 狀態 | 說明 | 包含建議 |
|
||||||
|--------|-----------|------|----------|
|
| ------------------------ | --------- | ---------------- | -------- |
|
||||||
| `UNAUTHORIZED` | 401 | 未授權存取 | ✗ |
|
| `UNAUTHORIZED` | 401 | 未授權存取 | ✗ |
|
||||||
| `INVALID_TOKEN` | 401 | Token 無效 | ✗ |
|
| `INVALID_TOKEN` | 401 | Token 無效 | ✗ |
|
||||||
| `TOKEN_EXPIRED` | 401 | Token 已過期 | ✗ |
|
| `TOKEN_EXPIRED` | 401 | Token 已過期 | ✗ |
|
||||||
| `MISSING_AUTH_HEADER` | 401 | 缺少認證標頭 | ✗ |
|
| `MISSING_AUTH_HEADER` | 401 | 缺少認證標頭 | ✗ |
|
||||||
| `BAD_REQUEST` | 400 | 請求格式錯誤 | ✗ |
|
| `BAD_REQUEST` | 400 | 請求格式錯誤 | ✗ |
|
||||||
| `INVALID_FILE` | 400 | 無法辨識檔案格式 | ✗ |
|
| `INVALID_FILE` | 400 | 無法辨識檔案格式 | ✗ |
|
||||||
| `FILE_TOO_LARGE` | 400 | 檔案超過大小限制 | ✗ |
|
| `FILE_TOO_LARGE` | 400 | 檔案超過大小限制 | ✗ |
|
||||||
| `ENGINE_NOT_FOUND` | 404 | 引擎不存在 | ✗ |
|
| `ENGINE_NOT_FOUND` | 404 | 引擎不存在 | ✗ |
|
||||||
| `JOB_NOT_FOUND` | 404 | 任務不存在 | ✗ |
|
| `JOB_NOT_FOUND` | 404 | 任務不存在 | ✗ |
|
||||||
| `FILE_NOT_FOUND` | 404 | 檔案不存在 | ✗ |
|
| `FILE_NOT_FOUND` | 404 | 檔案不存在 | ✗ |
|
||||||
| `UNSUPPORTED_CONVERSION` | 422 | 不支援的轉換 | ✓ |
|
| `UNSUPPORTED_CONVERSION` | 422 | 不支援的轉換 | ✓ |
|
||||||
| `CONVERSION_FAILED` | 500 | 轉換失敗 | ✗ |
|
| `CONVERSION_FAILED` | 500 | 轉換失敗 | ✗ |
|
||||||
| `INTERNAL_ERROR` | 500 | 內部錯誤 | ✗ |
|
| `INTERNAL_ERROR` | 500 | 內部錯誤 | ✗ |
|
||||||
|
|
|
||||||
|
|
@ -59,12 +59,14 @@
|
||||||
負責所有請求的 JWT 驗證。
|
負責所有請求的 JWT 驗證。
|
||||||
|
|
||||||
**職責**:
|
**職責**:
|
||||||
|
|
||||||
- 解析 Authorization Header
|
- 解析 Authorization Header
|
||||||
- 驗證 JWT Token 簽名
|
- 驗證 JWT Token 簽名
|
||||||
- 檢查 Token 是否過期
|
- 檢查 Token 是否過期
|
||||||
- 提取使用者資訊
|
- 提取使用者資訊
|
||||||
|
|
||||||
**不負責**:
|
**不負責**:
|
||||||
|
|
||||||
- 產生 JWT Token(由外部認證服務處理)
|
- 產生 JWT Token(由外部認證服務處理)
|
||||||
- 使用者管理
|
- 使用者管理
|
||||||
|
|
||||||
|
|
@ -73,6 +75,7 @@
|
||||||
提供 RESTful HTTP 端點。
|
提供 RESTful HTTP 端點。
|
||||||
|
|
||||||
**端點**:
|
**端點**:
|
||||||
|
|
||||||
- `GET /health` - 健康檢查
|
- `GET /health` - 健康檢查
|
||||||
- `GET /api/v1/engines` - 列出引擎
|
- `GET /api/v1/engines` - 列出引擎
|
||||||
- `POST /api/v1/convert` - 建立轉檔任務
|
- `POST /api/v1/convert` - 建立轉檔任務
|
||||||
|
|
@ -86,6 +89,7 @@
|
||||||
提供 GraphQL 查詢與變更。
|
提供 GraphQL 查詢與變更。
|
||||||
|
|
||||||
**Queries**:
|
**Queries**:
|
||||||
|
|
||||||
- `health` - 健康檢查
|
- `health` - 健康檢查
|
||||||
- `engines` - 列出引擎
|
- `engines` - 列出引擎
|
||||||
- `engine(id)` - 取得特定引擎
|
- `engine(id)` - 取得特定引擎
|
||||||
|
|
@ -95,6 +99,7 @@
|
||||||
- `suggestions` - 取得建議
|
- `suggestions` - 取得建議
|
||||||
|
|
||||||
**Mutations**:
|
**Mutations**:
|
||||||
|
|
||||||
- `createJob` - 建立任務
|
- `createJob` - 建立任務
|
||||||
- `deleteJob` - 刪除任務
|
- `deleteJob` - 刪除任務
|
||||||
|
|
||||||
|
|
@ -103,11 +108,13 @@
|
||||||
管理所有可用的轉換引擎及其能力。
|
管理所有可用的轉換引擎及其能力。
|
||||||
|
|
||||||
**職責**:
|
**職責**:
|
||||||
|
|
||||||
- 註冊引擎及其支援的格式
|
- 註冊引擎及其支援的格式
|
||||||
- 驗證轉換是否可行
|
- 驗證轉換是否可行
|
||||||
- 提供替代方案建議
|
- 提供替代方案建議
|
||||||
|
|
||||||
**設計原則**:
|
**設計原則**:
|
||||||
|
|
||||||
- 引擎必須明確指定,不自動選擇
|
- 引擎必須明確指定,不自動選擇
|
||||||
- 不支援的轉換必須回傳建議
|
- 不支援的轉換必須回傳建議
|
||||||
|
|
||||||
|
|
@ -116,6 +123,7 @@
|
||||||
處理轉檔任務的核心邏輯。
|
處理轉檔任務的核心邏輯。
|
||||||
|
|
||||||
**職責**:
|
**職責**:
|
||||||
|
|
||||||
- 建立和管理轉檔任務
|
- 建立和管理轉檔任務
|
||||||
- 儲存上傳的檔案
|
- 儲存上傳的檔案
|
||||||
- 呼叫外部轉換程式
|
- 呼叫外部轉換程式
|
||||||
|
|
@ -126,6 +134,7 @@
|
||||||
統一的錯誤處理機制。
|
統一的錯誤處理機制。
|
||||||
|
|
||||||
**特色**:
|
**特色**:
|
||||||
|
|
||||||
- 結構化的錯誤回應
|
- 結構化的錯誤回應
|
||||||
- 錯誤碼分類
|
- 錯誤碼分類
|
||||||
- 轉換建議整合
|
- 轉換建議整合
|
||||||
|
|
|
||||||
133
api-server/scripts/health_check.sh
Normal file
133
api-server/scripts/health_check.sh
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# health_check.sh - API Server Health Check Script
|
||||||
|
#
|
||||||
|
# This script performs health checks on the ConvertX API Server.
|
||||||
|
# It can be used for:
|
||||||
|
# - Docker container health checks
|
||||||
|
# - CI/CD pipeline readiness checks
|
||||||
|
# - Manual verification after deployment
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./health_check.sh [API_URL]
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# ./health_check.sh http://localhost:3001
|
||||||
|
# ./health_check.sh http://convertx-api:3001
|
||||||
|
#
|
||||||
|
# Exit codes:
|
||||||
|
# 0 - API is healthy
|
||||||
|
# 1 - API is unhealthy or unreachable
|
||||||
|
#
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Default API URL
|
||||||
|
API_URL="${1:-http://localhost:3001}"
|
||||||
|
TIMEOUT="${HEALTH_CHECK_TIMEOUT:-5}"
|
||||||
|
RETRIES="${HEALTH_CHECK_RETRIES:-3}"
|
||||||
|
RETRY_DELAY="${HEALTH_CHECK_DELAY:-2}"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
log_info() {
|
||||||
|
echo -e "${GREEN}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warn() {
|
||||||
|
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to check health endpoint
|
||||||
|
check_health() {
|
||||||
|
local url="$1/health"
|
||||||
|
local response
|
||||||
|
local http_code
|
||||||
|
|
||||||
|
# Make the request and capture both body and status code
|
||||||
|
response=$(curl -s -w "\n%{http_code}" --connect-timeout "$TIMEOUT" "$url" 2>/dev/null) || return 1
|
||||||
|
|
||||||
|
# Extract status code (last line) and body (everything else)
|
||||||
|
http_code=$(echo "$response" | tail -n1)
|
||||||
|
body=$(echo "$response" | sed '$d')
|
||||||
|
|
||||||
|
# Check HTTP status code
|
||||||
|
if [ "$http_code" != "200" ]; then
|
||||||
|
log_error "Health check returned HTTP $http_code"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check response body for "healthy" status
|
||||||
|
if echo "$body" | grep -q '"status":\s*"healthy"'; then
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log_error "Health check response does not indicate healthy status"
|
||||||
|
log_error "Response: $body"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to check engines endpoint
|
||||||
|
check_engines() {
|
||||||
|
local url="$1/api/v1/engines"
|
||||||
|
local response
|
||||||
|
local http_code
|
||||||
|
|
||||||
|
# Note: This endpoint requires authentication in production
|
||||||
|
# For basic connectivity check, we just verify it responds
|
||||||
|
response=$(curl -s -w "\n%{http_code}" --connect-timeout "$TIMEOUT" "$url" 2>/dev/null) || return 1
|
||||||
|
|
||||||
|
http_code=$(echo "$response" | tail -n1)
|
||||||
|
|
||||||
|
# 200 OK or 401 Unauthorized both indicate the server is responding
|
||||||
|
if [ "$http_code" = "200" ] || [ "$http_code" = "401" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main health check with retries
|
||||||
|
main() {
|
||||||
|
log_info "Checking ConvertX API Server at $API_URL"
|
||||||
|
log_info "Timeout: ${TIMEOUT}s, Retries: $RETRIES, Retry delay: ${RETRY_DELAY}s"
|
||||||
|
|
||||||
|
local attempt=1
|
||||||
|
|
||||||
|
while [ $attempt -le $RETRIES ]; do
|
||||||
|
log_info "Attempt $attempt of $RETRIES..."
|
||||||
|
|
||||||
|
if check_health "$API_URL"; then
|
||||||
|
log_info "✓ Health check passed"
|
||||||
|
|
||||||
|
if check_engines "$API_URL"; then
|
||||||
|
log_info "✓ Engines endpoint responding"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "API Server is healthy and ready!"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $attempt -lt $RETRIES ]; then
|
||||||
|
log_warn "Health check failed, retrying in ${RETRY_DELAY}s..."
|
||||||
|
sleep "$RETRY_DELAY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
log_error "Health check failed after $RETRIES attempts"
|
||||||
|
log_error "API Server at $API_URL is not responding correctly"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run main function
|
||||||
|
main
|
||||||
593
api-server/tests/integration_tests.rs
Normal file
593
api-server/tests/integration_tests.rs
Normal file
|
|
@ -0,0 +1,593 @@
|
||||||
|
//! Integration tests for API availability and file conversion
|
||||||
|
//!
|
||||||
|
//! These tests verify the API server is properly functioning
|
||||||
|
//! with real HTTP requests and actual file conversion.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use axum::http::{header, StatusCode};
|
||||||
|
use axum_test::TestServer;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tokio::time::sleep;
|
||||||
|
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||||
|
|
||||||
|
use convertx_api::{build_router, config::Config, AppState};
|
||||||
|
|
||||||
|
/// Create a test server with custom configuration
|
||||||
|
fn create_integration_test_server() -> TestServer {
|
||||||
|
let config = Config {
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: 3001,
|
||||||
|
jwt_secret: "integration-test-secret-key-32chars".to_string(),
|
||||||
|
upload_dir: "./test_data/uploads".to_string(),
|
||||||
|
output_dir: "./test_data/output".to_string(),
|
||||||
|
max_file_size: 10 * 1024 * 1024, // 10MB
|
||||||
|
jwt_expiration_secs: 3600,
|
||||||
|
};
|
||||||
|
let state = AppState::new(config);
|
||||||
|
let app = build_router(state);
|
||||||
|
TestServer::new(app).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a valid test JWT token
|
||||||
|
fn generate_test_token() -> String {
|
||||||
|
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct Claims {
|
||||||
|
sub: String,
|
||||||
|
exp: i64,
|
||||||
|
iat: i64,
|
||||||
|
email: Option<String>,
|
||||||
|
roles: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now().timestamp();
|
||||||
|
let claims = Claims {
|
||||||
|
sub: "integration-test-user".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("integration-test-secret-key-32chars".as_bytes()),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Health Check Integration Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
mod health_integration_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_health_endpoint_returns_healthy() {
|
||||||
|
let server = create_integration_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_v1_health_endpoint() {
|
||||||
|
let server = create_integration_test_server();
|
||||||
|
|
||||||
|
let response = server.get("/api/v1/health").await;
|
||||||
|
|
||||||
|
response.assert_status_ok();
|
||||||
|
|
||||||
|
let body: Value = response.json();
|
||||||
|
assert_eq!(body["status"], "healthy");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_health_does_not_require_auth() {
|
||||||
|
let server = create_integration_test_server();
|
||||||
|
|
||||||
|
// Health check should work without any authorization header
|
||||||
|
let response = server.get("/health").await;
|
||||||
|
|
||||||
|
response.assert_status_ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_health_response_contains_version() {
|
||||||
|
let server = create_integration_test_server();
|
||||||
|
|
||||||
|
let response = server.get("/health").await;
|
||||||
|
|
||||||
|
let body: Value = response.json();
|
||||||
|
let version = body["version"].as_str().unwrap();
|
||||||
|
|
||||||
|
// Version should be a valid semver-like string
|
||||||
|
assert!(!version.is_empty());
|
||||||
|
assert!(version.contains('.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// File Conversion Integration Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
mod conversion_integration_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Create a minimal valid PNG file for testing
|
||||||
|
fn create_test_png() -> Vec<u8> {
|
||||||
|
// Minimal 1x1 transparent PNG
|
||||||
|
vec![
|
||||||
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
|
||||||
|
0x00, 0x00, 0x00, 0x0D, // IHDR chunk length
|
||||||
|
0x49, 0x48, 0x44, 0x52, // IHDR
|
||||||
|
0x00, 0x00, 0x00, 0x01, // width: 1
|
||||||
|
0x00, 0x00, 0x00, 0x01, // height: 1
|
||||||
|
0x08, 0x06, // bit depth: 8, color type: RGBA
|
||||||
|
0x00, 0x00, 0x00, // compression, filter, interlace
|
||||||
|
0x1F, 0x15, 0xC4, 0x89, // CRC
|
||||||
|
0x00, 0x00, 0x00, 0x0A, // IDAT chunk length
|
||||||
|
0x49, 0x44, 0x41, 0x54, // IDAT
|
||||||
|
0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, // zlib data
|
||||||
|
0x0D, 0x0A, 0x2D, 0xB4, // CRC
|
||||||
|
0x00, 0x00, 0x00, 0x00, // IEND chunk length
|
||||||
|
0x49, 0x45, 0x4E, 0x44, // IEND
|
||||||
|
0xAE, 0x42, 0x60, 0x82, // CRC
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a minimal JSON file for testing
|
||||||
|
fn create_test_json() -> Vec<u8> {
|
||||||
|
r#"{"name": "test", "value": 123}"#.as_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_conversion_creates_job_with_valid_id() {
|
||||||
|
let server = create_integration_test_server();
|
||||||
|
let token = generate_test_token();
|
||||||
|
|
||||||
|
let file_content = create_test_png();
|
||||||
|
let file_base64 = STANDARD.encode(&file_content);
|
||||||
|
|
||||||
|
// Use GraphQL to create job (simpler than multipart for tests)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
"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();
|
||||||
|
|
||||||
|
// Check if job was created successfully
|
||||||
|
if body["data"]["createJob"]["success"] == true {
|
||||||
|
let job = &body["data"]["createJob"]["job"];
|
||||||
|
|
||||||
|
// Verify job ID is a valid UUID
|
||||||
|
let job_id = job["id"].as_str().unwrap();
|
||||||
|
assert!(uuid::Uuid::parse_str(job_id).is_ok());
|
||||||
|
|
||||||
|
// Verify job details
|
||||||
|
assert_eq!(job["originalFilename"], "test.png");
|
||||||
|
assert_eq!(job["sourceFormat"], "png");
|
||||||
|
assert_eq!(job["targetFormat"], "jpg");
|
||||||
|
assert_eq!(job["engine"], "imagemagick");
|
||||||
|
|
||||||
|
// Status should be pending or processing
|
||||||
|
let status = job["status"].as_str().unwrap();
|
||||||
|
assert!(status == "PENDING" || status == "PROCESSING");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_can_query_job_status_after_creation() {
|
||||||
|
let server = create_integration_test_server();
|
||||||
|
let token = generate_test_token();
|
||||||
|
|
||||||
|
let file_content = create_test_json();
|
||||||
|
let file_base64 = STANDARD.encode(&file_content);
|
||||||
|
|
||||||
|
// Create a job first
|
||||||
|
let create_query = json!({
|
||||||
|
"query": r#"
|
||||||
|
mutation($filename: String!, $fileBase64: String!, $input: CreateJobInput!) {
|
||||||
|
createJob(filename: $filename, fileBase64: $fileBase64, input: $input) {
|
||||||
|
success
|
||||||
|
job { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
"variables": {
|
||||||
|
"filename": "test.json",
|
||||||
|
"fileBase64": file_base64,
|
||||||
|
"input": {
|
||||||
|
"engine": "dasel",
|
||||||
|
"targetFormat": "yaml"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let create_response = server
|
||||||
|
.post("/graphql")
|
||||||
|
.add_header(header::AUTHORIZATION, format!("Bearer {}", token))
|
||||||
|
.content_type("application/json")
|
||||||
|
.json(&create_query)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
create_response.assert_status_ok();
|
||||||
|
let create_body: Value = create_response.json();
|
||||||
|
|
||||||
|
if create_body["data"]["createJob"]["success"] == true {
|
||||||
|
let job_id = create_body["data"]["createJob"]["job"]["id"].as_str().unwrap();
|
||||||
|
|
||||||
|
// Query the job status
|
||||||
|
let status_query = json!({
|
||||||
|
"query": format!(r#"
|
||||||
|
query {{
|
||||||
|
job(id: "{}") {{
|
||||||
|
id
|
||||||
|
status
|
||||||
|
originalFilename
|
||||||
|
engine
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
"#, job_id)
|
||||||
|
});
|
||||||
|
|
||||||
|
let status_response = server
|
||||||
|
.post("/graphql")
|
||||||
|
.add_header(header::AUTHORIZATION, format!("Bearer {}", token))
|
||||||
|
.content_type("application/json")
|
||||||
|
.json(&status_query)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
status_response.assert_status_ok();
|
||||||
|
let status_body: Value = status_response.json();
|
||||||
|
|
||||||
|
// Job should be found
|
||||||
|
let job = &status_body["data"]["job"];
|
||||||
|
assert!(!job.is_null());
|
||||||
|
assert_eq!(job["id"], job_id);
|
||||||
|
assert_eq!(job["originalFilename"], "test.json");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_job_list_shows_created_jobs() {
|
||||||
|
let server = create_integration_test_server();
|
||||||
|
let token = generate_test_token();
|
||||||
|
|
||||||
|
// Get initial job count
|
||||||
|
let initial_query = json!({
|
||||||
|
"query": "{ jobs { id } }"
|
||||||
|
});
|
||||||
|
|
||||||
|
let initial_response = server
|
||||||
|
.post("/graphql")
|
||||||
|
.add_header(header::AUTHORIZATION, format!("Bearer {}", token))
|
||||||
|
.content_type("application/json")
|
||||||
|
.json(&initial_query)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
initial_response.assert_status_ok();
|
||||||
|
let initial_body: Value = initial_response.json();
|
||||||
|
let initial_count = initial_body["data"]["jobs"].as_array().unwrap().len();
|
||||||
|
|
||||||
|
// Create a new job
|
||||||
|
let file_content = create_test_png();
|
||||||
|
let file_base64 = STANDARD.encode(&file_content);
|
||||||
|
|
||||||
|
let create_query = json!({
|
||||||
|
"query": r#"
|
||||||
|
mutation($filename: String!, $fileBase64: String!, $input: CreateJobInput!) {
|
||||||
|
createJob(filename: $filename, fileBase64: $fileBase64, input: $input) {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
"variables": {
|
||||||
|
"filename": "list_test.png",
|
||||||
|
"fileBase64": file_base64,
|
||||||
|
"input": {
|
||||||
|
"engine": "imagemagick",
|
||||||
|
"targetFormat": "jpg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = server
|
||||||
|
.post("/graphql")
|
||||||
|
.add_header(header::AUTHORIZATION, format!("Bearer {}", token))
|
||||||
|
.content_type("application/json")
|
||||||
|
.json(&create_query)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Check job list again
|
||||||
|
let final_response = server
|
||||||
|
.post("/graphql")
|
||||||
|
.add_header(header::AUTHORIZATION, format!("Bearer {}", token))
|
||||||
|
.content_type("application/json")
|
||||||
|
.json(&initial_query)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
final_response.assert_status_ok();
|
||||||
|
let final_body: Value = final_response.json();
|
||||||
|
let final_count = final_body["data"]["jobs"].as_array().unwrap().len();
|
||||||
|
|
||||||
|
// Should have at least one more job (or same if creation failed due to missing tools)
|
||||||
|
assert!(final_count >= initial_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_unsupported_conversion_returns_suggestions() {
|
||||||
|
let server = create_integration_test_server();
|
||||||
|
let token = generate_test_token();
|
||||||
|
|
||||||
|
// Try to convert PDF with FFmpeg (not supported)
|
||||||
|
let file_content = b"%PDF-1.4\n".to_vec();
|
||||||
|
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_eq!(body["data"]["createJob"]["error"]["code"], "UNSUPPORTED_CONVERSION");
|
||||||
|
|
||||||
|
// Should have suggestions array
|
||||||
|
let suggestions = &body["data"]["createJob"]["error"]["suggestions"];
|
||||||
|
assert!(suggestions.is_array());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// REST API Integration Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
mod rest_integration_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_rest_engines_list() {
|
||||||
|
let server = create_integration_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();
|
||||||
|
let engines = body["engines"].as_array().unwrap();
|
||||||
|
|
||||||
|
// Should have multiple engines
|
||||||
|
assert!(engines.len() > 10);
|
||||||
|
|
||||||
|
// Check some expected engines exist
|
||||||
|
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"));
|
||||||
|
assert!(engine_ids.contains(&"pandoc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_rest_single_engine_info() {
|
||||||
|
let server = create_integration_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!(body["supported_input_formats"].is_array());
|
||||||
|
assert!(body["supported_output_formats"].is_array());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_rest_jobs_list_initially_empty_for_user() {
|
||||||
|
let server = create_integration_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!(body["total"].is_number());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_rest_job_not_found() {
|
||||||
|
let server = create_integration_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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// GraphQL Integration Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
mod graphql_integration_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_graphql_playground_available() {
|
||||||
|
let server = create_integration_test_server();
|
||||||
|
|
||||||
|
let response = server.get("/graphql").await;
|
||||||
|
|
||||||
|
response.assert_status_ok();
|
||||||
|
|
||||||
|
let body = response.text();
|
||||||
|
assert!(body.contains("GraphQL") || body.contains("graphql"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_graphql_validate_conversion() {
|
||||||
|
let server = create_integration_test_server();
|
||||||
|
let token = generate_test_token();
|
||||||
|
|
||||||
|
// Test valid conversion
|
||||||
|
let valid_query = json!({
|
||||||
|
"query": "{ validateConversion(engine: \"ffmpeg\", from: \"mp4\", to: \"webm\") { success } }"
|
||||||
|
});
|
||||||
|
|
||||||
|
let valid_response = server
|
||||||
|
.post("/graphql")
|
||||||
|
.add_header(header::AUTHORIZATION, format!("Bearer {}", token))
|
||||||
|
.content_type("application/json")
|
||||||
|
.json(&valid_query)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
valid_response.assert_status_ok();
|
||||||
|
let valid_body: Value = valid_response.json();
|
||||||
|
assert_eq!(valid_body["data"]["validateConversion"]["success"], true);
|
||||||
|
|
||||||
|
// Test invalid conversion
|
||||||
|
let invalid_query = json!({
|
||||||
|
"query": "{ validateConversion(engine: \"ffmpeg\", from: \"pdf\", to: \"mp4\") { success error { code suggestions { engine } } } }"
|
||||||
|
});
|
||||||
|
|
||||||
|
let invalid_response = server
|
||||||
|
.post("/graphql")
|
||||||
|
.add_header(header::AUTHORIZATION, format!("Bearer {}", token))
|
||||||
|
.content_type("application/json")
|
||||||
|
.json(&invalid_query)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
invalid_response.assert_status_ok();
|
||||||
|
let invalid_body: Value = invalid_response.json();
|
||||||
|
assert_eq!(invalid_body["data"]["validateConversion"]["success"], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_graphql_suggestions_query() {
|
||||||
|
let server = create_integration_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();
|
||||||
|
|
||||||
|
let suggestions = body["data"]["suggestions"].as_array().unwrap();
|
||||||
|
assert!(!suggestions.is_empty());
|
||||||
|
|
||||||
|
// Should suggest imagemagick or vips for png->jpg
|
||||||
|
let has_valid_suggestion = suggestions.iter().any(|s| {
|
||||||
|
s["engine"] == "imagemagick" || s["engine"] == "vips"
|
||||||
|
});
|
||||||
|
assert!(has_valid_suggestion);
|
||||||
|
}
|
||||||
|
}
|
||||||
138
compose.yaml
138
compose.yaml
|
|
@ -19,6 +19,27 @@
|
||||||
# ⚠️ 部署前必做:
|
# ⚠️ 部署前必做:
|
||||||
# 1. 先建立 data 資料夾(見 README.md 步驟 2)
|
# 1. 先建立 data 資料夾(見 README.md 步驟 2)
|
||||||
# 2. 修改 JWT_SECRET 為你自己的隨機字串
|
# 2. 修改 JWT_SECRET 為你自己的隨機字串
|
||||||
|
#
|
||||||
|
# ==============================================================================
|
||||||
|
# 🆕 API Server(選用功能)
|
||||||
|
# ==============================================================================
|
||||||
|
#
|
||||||
|
# ConvertX 現在提供獨立的 REST / GraphQL API Server!
|
||||||
|
#
|
||||||
|
# ⚠️ 預設不啟動 API Server
|
||||||
|
# 一般使用者只需要 Web UI,不需要啟動 API
|
||||||
|
#
|
||||||
|
# 🚀 如何啟用 API Server?
|
||||||
|
# 使用 profile 功能:
|
||||||
|
#
|
||||||
|
# # 只啟動 Web UI(預設行為)
|
||||||
|
# docker compose up -d
|
||||||
|
#
|
||||||
|
# # 同時啟動 Web UI + API Server
|
||||||
|
# docker compose --profile api up -d
|
||||||
|
#
|
||||||
|
# 📖 API 文件請參考:api-server/README.md
|
||||||
|
#
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
@ -192,3 +213,120 @@ services:
|
||||||
|
|
||||||
# 最大同時轉換數(0 = 無限制)
|
# 最大同時轉換數(0 = 無限制)
|
||||||
# - MAX_CONVERT_PROCESS=4
|
# - MAX_CONVERT_PROCESS=4
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# ConvertX API Server(選用服務)
|
||||||
|
# ===========================================================================
|
||||||
|
#
|
||||||
|
# 🆕 REST + GraphQL API 伺服器
|
||||||
|
#
|
||||||
|
# ⚠️ 這是選用服務,預設不啟動!
|
||||||
|
# 使用 --profile api 才會啟動
|
||||||
|
#
|
||||||
|
# 🚀 啟動方式:
|
||||||
|
# docker compose --profile api up -d
|
||||||
|
#
|
||||||
|
# 📖 API 端點:
|
||||||
|
# - REST API: http://localhost:3001/api/v1/
|
||||||
|
# - GraphQL: http://localhost:3001/graphql
|
||||||
|
# - 健康檢查: http://localhost:3001/health
|
||||||
|
#
|
||||||
|
# 🔐 認證方式:
|
||||||
|
# 所有 API(除健康檢查)都需要 JWT Bearer Token
|
||||||
|
# Token 需由外部認證服務產生
|
||||||
|
#
|
||||||
|
# ===========================================================================
|
||||||
|
convertx-api:
|
||||||
|
# 使用 profile 使其成為選用服務
|
||||||
|
profiles:
|
||||||
|
- api
|
||||||
|
|
||||||
|
# 使用本地 Dockerfile 建置
|
||||||
|
build:
|
||||||
|
context: ./api-server
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
|
||||||
|
# 或使用預建的 image(取消下面的註解)
|
||||||
|
# image: convertx/convertx-api:latest
|
||||||
|
|
||||||
|
container_name: convertx-api
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 連接埠設定
|
||||||
|
# =========================================================================
|
||||||
|
# API Server 預設使用 3001 埠,避免與 Web UI (3000) 衝突
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 資料儲存設定
|
||||||
|
# =========================================================================
|
||||||
|
# 與 Web UI 共用相同的 data 目錄
|
||||||
|
# API Server 會在 data/uploads 和 data/output 下建立子目錄
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 環境變數設定
|
||||||
|
# =========================================================================
|
||||||
|
# 可使用 env_file 載入 .env.api 檔案
|
||||||
|
env_file:
|
||||||
|
- path: .env.api
|
||||||
|
required: false
|
||||||
|
|
||||||
|
environment:
|
||||||
|
# 時區設定
|
||||||
|
- TZ=Asia/Taipei
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# API Server 專用設定
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# API 監聽地址與埠
|
||||||
|
- API_HOST=0.0.0.0
|
||||||
|
- API_PORT=3001
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# JWT 密鑰(🔐 必須設定)
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# ⚠️ 這是 API Server 驗證 Token 用的密鑰
|
||||||
|
# 必須與產生 Token 的認證服務使用相同的密鑰
|
||||||
|
#
|
||||||
|
# 🚨 請務必修改為你自己的密鑰!
|
||||||
|
- JWT_SECRET=${API_JWT_SECRET:-請設定你的API專用JWT密鑰}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# 檔案儲存路徑
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
- UPLOAD_DIR=/app/data/api-uploads
|
||||||
|
- OUTPUT_DIR=/app/data/api-output
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# 其他設定
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# 最大檔案大小(預設 100MB)
|
||||||
|
- MAX_FILE_SIZE=104857600
|
||||||
|
|
||||||
|
# 日誌級別
|
||||||
|
- RUST_LOG=convertx_api=info,tower_http=info
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 健康檢查
|
||||||
|
# =========================================================================
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:3001/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 資源限制(可選)
|
||||||
|
# =========================================================================
|
||||||
|
# deploy:
|
||||||
|
# resources:
|
||||||
|
# limits:
|
||||||
|
# memory: 2G
|
||||||
|
# reservations:
|
||||||
|
# memory: 512M
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue