feat: Add Rust API Server with REST and GraphQL support

- Implement JWT authentication layer
- Add Engine Registry with 20+ conversion engines
- Implement Conversion Service with background job processing
- REST API endpoints for file conversion operations
- GraphQL API with queries and mutations
- Comprehensive error handling with conversion suggestions
- Integration tests for both REST and GraphQL APIs
- Complete API documentation

Features:
- REST API: /api/v1/* endpoints
- GraphQL API: /graphql endpoint with playground
- JWT Bearer token authentication
- Engine validation with alternative suggestions
- File download via API (not exposing file paths)
- Support for FFmpeg, ImageMagick, LibreOffice, Pandoc, and more
This commit is contained in:
Your Name 2026-01-21 11:40:03 +08:00
parent d0388066a5
commit e083e5d11d
18 changed files with 5235 additions and 0 deletions

278
api-server/src/rest.rs Normal file
View file

@ -0,0 +1,278 @@
//! REST API module
//!
//! Provides REST endpoints for file conversion operations.
use axum::{
extract::{Multipart, Path, State},
http::StatusCode,
response::IntoResponse,
routing::{delete, get, post},
Json, Router,
};
use serde_json::json;
use tokio::fs::File;
use tokio::io::AsyncReadExt;
use uuid::Uuid;
use crate::auth::RequireAuth;
use crate::error::{ApiError, ApiResult};
use crate::models::{
CreateJobResponse, HealthResponse, JobStatusResponse, ListEnginesResponse, ListJobsResponse,
};
use crate::AppState;
/// Create REST API routes
pub fn routes() -> Router<AppState> {
Router::new()
// Health check (no auth required)
.route("/health", get(health_check))
.route("/api/v1/health", get(health_check))
// Protected routes
.route("/api/v1/engines", get(list_engines))
.route("/api/v1/engines/:engine_id", get(get_engine))
.route("/api/v1/engines/:engine_id/conversions", get(get_engine_conversions))
.route("/api/v1/convert", post(create_conversion_job))
.route("/api/v1/jobs", get(list_jobs))
.route("/api/v1/jobs/:job_id", get(get_job_status))
.route("/api/v1/jobs/:job_id", delete(delete_job))
.route("/api/v1/jobs/:job_id/download", get(download_result))
}
/// Health check endpoint
async fn health_check() -> Json<HealthResponse> {
Json(HealthResponse {
status: "healthy".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
timestamp: chrono::Utc::now(),
})
}
/// List all available conversion engines
async fn list_engines(
State(state): State<AppState>,
RequireAuth(_user): RequireAuth,
) -> Json<ListEnginesResponse> {
let engines = state.engine_registry.list_info();
Json(ListEnginesResponse { engines })
}
/// Get a specific engine's details
async fn get_engine(
State(state): State<AppState>,
RequireAuth(_user): RequireAuth,
Path(engine_id): Path<String>,
) -> ApiResult<impl IntoResponse> {
let engine = state
.engine_registry
.get(&engine_id)
.ok_or_else(|| ApiError::EngineNotFound(engine_id.clone()))?;
Ok(Json(engine.to_info()))
}
/// Get supported conversions for an engine
async fn get_engine_conversions(
State(state): State<AppState>,
RequireAuth(_user): RequireAuth,
Path(engine_id): Path<String>,
) -> ApiResult<impl IntoResponse> {
let engine = state
.engine_registry
.get(&engine_id)
.ok_or_else(|| ApiError::EngineNotFound(engine_id.clone()))?;
Ok(Json(json!({
"engine_id": engine.id,
"conversions": engine.conversions,
})))
}
/// Create a new conversion job
///
/// Expects multipart form data with:
/// - file: The file to convert
/// - engine: The conversion engine to use
/// - target_format: The target format
/// - options: (optional) JSON string of conversion options
async fn create_conversion_job(
State(state): State<AppState>,
RequireAuth(user): RequireAuth,
mut multipart: Multipart,
) -> ApiResult<impl IntoResponse> {
let mut file_data: Option<Vec<u8>> = None;
let mut filename: Option<String> = None;
let mut engine: Option<String> = None;
let mut target_format: Option<String> = None;
let mut options: Option<serde_json::Value> = None;
// Parse multipart form data
while let Some(field) = multipart.next_field().await
.map_err(|e| ApiError::BadRequest(format!("Failed to read multipart field: {}", e)))?
{
let name = field.name().unwrap_or_default().to_string();
match name.as_str() {
"file" => {
filename = field.file_name().map(|s| s.to_string());
let data = field.bytes().await
.map_err(|e| ApiError::BadRequest(format!("Failed to read file: {}", e)))?;
// Check file size
if data.len() > state.config.max_file_size {
return Err(ApiError::FileTooLarge {
max_size: state.config.max_file_size,
});
}
file_data = Some(data.to_vec());
}
"engine" => {
let text = field.text().await
.map_err(|e| ApiError::BadRequest(format!("Failed to read engine: {}", e)))?;
engine = Some(text);
}
"target_format" => {
let text = field.text().await
.map_err(|e| ApiError::BadRequest(format!("Failed to read target_format: {}", e)))?;
target_format = Some(text);
}
"options" => {
let text = field.text().await
.map_err(|e| ApiError::BadRequest(format!("Failed to read options: {}", e)))?;
if !text.is_empty() {
options = Some(serde_json::from_str(&text)
.map_err(|e| ApiError::BadRequest(format!("Invalid options JSON: {}", e)))?);
}
}
_ => {
// Ignore unknown fields
}
}
}
// Validate required fields
let file_data = file_data.ok_or_else(|| ApiError::BadRequest("Missing file".into()))?;
let filename = filename.ok_or_else(|| ApiError::BadRequest("Missing filename".into()))?;
let engine = engine.ok_or_else(|| ApiError::BadRequest("Missing engine parameter".into()))?;
let target_format = target_format
.ok_or_else(|| ApiError::BadRequest("Missing target_format parameter".into()))?;
// Create the conversion job
let job = state
.conversion_service
.create_job(user.user_id, filename, engine, target_format, options, file_data)
.await?;
Ok((
StatusCode::CREATED,
Json(CreateJobResponse {
job_id: job.id,
status: job.status,
message: "Conversion job created successfully".to_string(),
}),
))
}
/// List all jobs for the authenticated user
async fn list_jobs(
State(state): State<AppState>,
RequireAuth(user): RequireAuth,
) -> Json<ListJobsResponse> {
let jobs = state.conversion_service.get_user_jobs(&user.user_id).await;
let job_responses: Vec<JobStatusResponse> = jobs.iter().map(|j| j.into()).collect();
let total = job_responses.len();
Json(ListJobsResponse {
jobs: job_responses,
total,
})
}
/// Get the status of a specific job
async fn get_job_status(
State(state): State<AppState>,
RequireAuth(user): RequireAuth,
Path(job_id): Path<Uuid>,
) -> ApiResult<Json<JobStatusResponse>> {
let job = state.conversion_service.get_job(job_id).await?;
// Verify ownership
if job.user_id != user.user_id {
return Err(ApiError::Unauthorized("Not authorized to view this job".into()));
}
Ok(Json((&job).into()))
}
/// Delete a job
async fn delete_job(
State(state): State<AppState>,
RequireAuth(user): RequireAuth,
Path(job_id): Path<Uuid>,
) -> ApiResult<impl IntoResponse> {
state
.conversion_service
.delete_job(job_id, &user.user_id)
.await?;
Ok((
StatusCode::OK,
Json(json!({
"message": "Job deleted successfully",
"job_id": job_id,
})),
))
}
/// Download the converted file
async fn download_result(
State(state): State<AppState>,
RequireAuth(user): RequireAuth,
Path(job_id): Path<Uuid>,
) -> ApiResult<impl IntoResponse> {
let file_path = state
.conversion_service
.get_output_file(job_id, &user.user_id)
.await?;
// Read file
let mut file = File::open(&file_path).await
.map_err(|e| ApiError::InternalError(format!("Failed to open file: {}", e)))?;
let mut contents = Vec::new();
file.read_to_end(&mut contents).await
.map_err(|e| ApiError::InternalError(format!("Failed to read file: {}", e)))?;
// Determine content type
let content_type = mime_guess::from_path(&file_path)
.first_or_octet_stream()
.to_string();
let filename = file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("download");
Ok((
StatusCode::OK,
[
("content-type", content_type),
(
"content-disposition",
format!("attachment; filename=\"{}\"", filename),
),
],
contents,
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_routes_are_defined() {
// Just verify routes can be created
let _routes = routes();
}
}