feat: Add Rust API Server with REST and GraphQL support
- Implement JWT authentication layer - Add Engine Registry with 20+ conversion engines - Implement Conversion Service with background job processing - REST API endpoints for file conversion operations - GraphQL API with queries and mutations - Comprehensive error handling with conversion suggestions - Integration tests for both REST and GraphQL APIs - Complete API documentation Features: - REST API: /api/v1/* endpoints - GraphQL API: /graphql endpoint with playground - JWT Bearer token authentication - Engine validation with alternative suggestions - File download via API (not exposing file paths) - Support for FFmpeg, ImageMagick, LibreOffice, Pandoc, and more
This commit is contained in:
parent
d0388066a5
commit
e083e5d11d
18 changed files with 5235 additions and 0 deletions
526
api-server/src/graphql.rs
Normal file
526
api-server/src/graphql.rs
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
//! GraphQL API module
|
||||
//!
|
||||
//! Provides GraphQL endpoints for file conversion operations.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_graphql::{
|
||||
Context, EmptySubscription, Enum, InputObject, Object, Result as GqlResult, Schema,
|
||||
SimpleObject, Upload, ID,
|
||||
};
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use async_graphql_axum::{GraphQLRequest, GraphQLResponse};
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::{JwtValidator, AuthenticatedUser, Claims};
|
||||
use crate::engine::EngineRegistry;
|
||||
use crate::conversion::ConversionService;
|
||||
use crate::config::Config;
|
||||
use crate::error::ConversionSuggestion;
|
||||
use crate::models::JobStatus;
|
||||
|
||||
/// GraphQL Schema type
|
||||
pub type ApiSchema = Schema<QueryRoot, MutationRoot, EmptySubscription>;
|
||||
|
||||
/// Create GraphQL routes
|
||||
pub fn routes() -> Router<crate::AppState> {
|
||||
Router::new()
|
||||
.route("/graphql", post(graphql_handler))
|
||||
.route("/graphql", get(graphql_playground))
|
||||
}
|
||||
|
||||
/// GraphQL handler
|
||||
async fn graphql_handler(
|
||||
State(state): State<crate::AppState>,
|
||||
headers: HeaderMap,
|
||||
req: GraphQLRequest,
|
||||
) -> GraphQLResponse {
|
||||
let schema = build_schema(
|
||||
state.engine_registry.clone(),
|
||||
state.conversion_service.clone(),
|
||||
state.config.clone(),
|
||||
);
|
||||
|
||||
// Extract JWT from headers and add to context
|
||||
let mut request = req.into_inner();
|
||||
|
||||
if let Some(auth_header) = headers.get("authorization") {
|
||||
if let Ok(auth_str) = auth_header.to_str() {
|
||||
request = request.data(AuthHeader(auth_str.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
schema.execute(request).await.into()
|
||||
}
|
||||
|
||||
/// GraphQL Playground handler
|
||||
async fn graphql_playground() -> impl axum::response::IntoResponse {
|
||||
axum::response::Html(async_graphql::http::playground_source(
|
||||
async_graphql::http::GraphQLPlaygroundConfig::new("/graphql"),
|
||||
))
|
||||
}
|
||||
|
||||
/// Build the GraphQL schema
|
||||
pub fn build_schema(
|
||||
engine_registry: Arc<EngineRegistry>,
|
||||
conversion_service: Arc<ConversionService>,
|
||||
config: Arc<Config>,
|
||||
) -> ApiSchema {
|
||||
Schema::build(QueryRoot, MutationRoot, EmptySubscription)
|
||||
.data(engine_registry)
|
||||
.data(conversion_service)
|
||||
.data(config)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Authorization header wrapper
|
||||
struct AuthHeader(String);
|
||||
|
||||
/// Validate JWT from context
|
||||
fn validate_auth(ctx: &Context<'_>) -> GqlResult<AuthenticatedUser> {
|
||||
let config = ctx.data::<Arc<Config>>()?;
|
||||
let auth_header = ctx.data::<AuthHeader>()
|
||||
.map_err(|_| async_graphql::Error::new("Missing authorization header"))?;
|
||||
|
||||
let validator = JwtValidator::new(&config.jwt_secret);
|
||||
|
||||
let token = JwtValidator::extract_token(&auth_header.0)
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
|
||||
let claims = validator.validate(token)
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
|
||||
Ok(AuthenticatedUser {
|
||||
user_id: claims.sub.clone(),
|
||||
email: claims.email.clone(),
|
||||
roles: claims.roles.clone(),
|
||||
claims,
|
||||
})
|
||||
}
|
||||
|
||||
// ============ GraphQL Types ============
|
||||
|
||||
/// Job status enum for GraphQL
|
||||
#[derive(Enum, Copy, Clone, Eq, PartialEq)]
|
||||
pub enum GqlJobStatus {
|
||||
Pending,
|
||||
Processing,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl From<JobStatus> for GqlJobStatus {
|
||||
fn from(status: JobStatus) -> Self {
|
||||
match status {
|
||||
JobStatus::Pending => GqlJobStatus::Pending,
|
||||
JobStatus::Processing => GqlJobStatus::Processing,
|
||||
JobStatus::Completed => GqlJobStatus::Completed,
|
||||
JobStatus::Failed => GqlJobStatus::Failed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Engine information
|
||||
#[derive(SimpleObject)]
|
||||
pub struct GqlEngine {
|
||||
/// Engine identifier
|
||||
pub id: ID,
|
||||
/// Human-readable name
|
||||
pub name: String,
|
||||
/// Description
|
||||
pub description: String,
|
||||
/// Supported input formats
|
||||
pub supported_input_formats: Vec<String>,
|
||||
/// Supported output formats
|
||||
pub supported_output_formats: Vec<String>,
|
||||
}
|
||||
|
||||
/// Conversion job
|
||||
#[derive(SimpleObject)]
|
||||
pub struct GqlJob {
|
||||
/// Unique job identifier
|
||||
pub id: ID,
|
||||
/// Original filename
|
||||
pub original_filename: String,
|
||||
/// Source format
|
||||
pub source_format: String,
|
||||
/// Target format
|
||||
pub target_format: String,
|
||||
/// Engine used
|
||||
pub engine: String,
|
||||
/// Current status
|
||||
pub status: GqlJobStatus,
|
||||
/// Output filename (when completed)
|
||||
pub output_filename: Option<String>,
|
||||
/// Error message (when failed)
|
||||
pub error_message: Option<String>,
|
||||
/// Download URL (when completed)
|
||||
pub download_url: Option<String>,
|
||||
/// Creation timestamp
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// Completion timestamp
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Conversion suggestion
|
||||
#[derive(SimpleObject)]
|
||||
pub struct GqlSuggestion {
|
||||
/// Suggested engine
|
||||
pub engine: String,
|
||||
/// Source format
|
||||
pub from: String,
|
||||
/// Target format
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
impl From<ConversionSuggestion> for GqlSuggestion {
|
||||
fn from(s: ConversionSuggestion) -> Self {
|
||||
Self {
|
||||
engine: s.engine,
|
||||
from: s.from,
|
||||
to: s.to,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversion error with suggestions
|
||||
#[derive(SimpleObject)]
|
||||
pub struct GqlConversionError {
|
||||
/// Error code
|
||||
pub code: String,
|
||||
/// Error message
|
||||
pub message: String,
|
||||
/// Suggestions for alternative conversions
|
||||
pub suggestions: Vec<GqlSuggestion>,
|
||||
}
|
||||
|
||||
/// Result of creating a job
|
||||
#[derive(SimpleObject)]
|
||||
pub struct CreateJobResult {
|
||||
/// Whether the operation succeeded
|
||||
pub success: bool,
|
||||
/// The created job (if successful)
|
||||
pub job: Option<GqlJob>,
|
||||
/// Error information (if failed)
|
||||
pub error: Option<GqlConversionError>,
|
||||
}
|
||||
|
||||
/// Input for creating a conversion job
|
||||
#[derive(InputObject)]
|
||||
pub struct CreateJobInput {
|
||||
/// Engine to use for conversion
|
||||
pub engine: String,
|
||||
/// Target format
|
||||
pub target_format: String,
|
||||
/// Conversion options (JSON)
|
||||
pub options: Option<String>,
|
||||
}
|
||||
|
||||
/// Health status
|
||||
#[derive(SimpleObject)]
|
||||
pub struct GqlHealth {
|
||||
pub status: String,
|
||||
pub version: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
// ============ Query Root ============
|
||||
|
||||
pub struct QueryRoot;
|
||||
|
||||
#[Object]
|
||||
impl QueryRoot {
|
||||
/// Health check
|
||||
async fn health(&self) -> GqlHealth {
|
||||
GqlHealth {
|
||||
status: "healthy".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
timestamp: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all available conversion engines
|
||||
async fn engines(&self, ctx: &Context<'_>) -> GqlResult<Vec<GqlEngine>> {
|
||||
let _user = validate_auth(ctx)?;
|
||||
let registry = ctx.data::<Arc<EngineRegistry>>()?;
|
||||
|
||||
let engines: Vec<GqlEngine> = registry
|
||||
.list_info()
|
||||
.into_iter()
|
||||
.map(|e| GqlEngine {
|
||||
id: ID(e.id),
|
||||
name: e.name,
|
||||
description: e.description,
|
||||
supported_input_formats: e.supported_input_formats,
|
||||
supported_output_formats: e.supported_output_formats,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(engines)
|
||||
}
|
||||
|
||||
/// Get a specific engine
|
||||
async fn engine(&self, ctx: &Context<'_>, id: ID) -> GqlResult<Option<GqlEngine>> {
|
||||
let _user = validate_auth(ctx)?;
|
||||
let registry = ctx.data::<Arc<EngineRegistry>>()?;
|
||||
|
||||
Ok(registry.get(&id.0).map(|e| GqlEngine {
|
||||
id: ID(e.id.clone()),
|
||||
name: e.name.clone(),
|
||||
description: e.description.clone(),
|
||||
supported_input_formats: e.input_formats(),
|
||||
supported_output_formats: e.output_formats(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get all jobs for the authenticated user
|
||||
async fn jobs(&self, ctx: &Context<'_>) -> GqlResult<Vec<GqlJob>> {
|
||||
let user = validate_auth(ctx)?;
|
||||
let service = ctx.data::<Arc<ConversionService>>()?;
|
||||
|
||||
let jobs = service.get_user_jobs(&user.user_id).await;
|
||||
|
||||
Ok(jobs
|
||||
.into_iter()
|
||||
.map(|j| GqlJob {
|
||||
id: ID(j.id.to_string()),
|
||||
original_filename: j.original_filename,
|
||||
source_format: j.source_format,
|
||||
target_format: j.target_format,
|
||||
engine: j.engine,
|
||||
status: j.status.into(),
|
||||
output_filename: j.output_filename,
|
||||
error_message: j.error_message,
|
||||
download_url: if j.status == JobStatus::Completed {
|
||||
Some(format!("/api/v1/jobs/{}/download", j.id))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
created_at: j.created_at,
|
||||
completed_at: j.completed_at,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Get a specific job
|
||||
async fn job(&self, ctx: &Context<'_>, id: ID) -> GqlResult<Option<GqlJob>> {
|
||||
let user = validate_auth(ctx)?;
|
||||
let service = ctx.data::<Arc<ConversionService>>()?;
|
||||
|
||||
let job_id = Uuid::parse_str(&id.0)
|
||||
.map_err(|_| async_graphql::Error::new("Invalid job ID format"))?;
|
||||
|
||||
match service.get_job(job_id).await {
|
||||
Ok(job) => {
|
||||
if job.user_id != user.user_id {
|
||||
return Err(async_graphql::Error::new("Not authorized to view this job"));
|
||||
}
|
||||
Ok(Some(GqlJob {
|
||||
id: ID(job.id.to_string()),
|
||||
original_filename: job.original_filename,
|
||||
source_format: job.source_format,
|
||||
target_format: job.target_format,
|
||||
engine: job.engine,
|
||||
status: job.status.into(),
|
||||
output_filename: job.output_filename,
|
||||
error_message: job.error_message,
|
||||
download_url: if job.status == JobStatus::Completed {
|
||||
Some(format!("/api/v1/jobs/{}/download", job.id))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
created_at: job.created_at,
|
||||
completed_at: job.completed_at,
|
||||
}))
|
||||
}
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate if a conversion is supported
|
||||
async fn validate_conversion(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
engine: String,
|
||||
from: String,
|
||||
to: String,
|
||||
) -> GqlResult<CreateJobResult> {
|
||||
let _user = validate_auth(ctx)?;
|
||||
let registry = ctx.data::<Arc<EngineRegistry>>()?;
|
||||
|
||||
match registry.validate_conversion(&engine, &from, &to) {
|
||||
Ok(_) => Ok(CreateJobResult {
|
||||
success: true,
|
||||
job: None,
|
||||
error: None,
|
||||
}),
|
||||
Err(crate::error::ApiError::UnsupportedConversion {
|
||||
engine,
|
||||
from,
|
||||
to,
|
||||
suggestions,
|
||||
}) => Ok(CreateJobResult {
|
||||
success: false,
|
||||
job: None,
|
||||
error: Some(GqlConversionError {
|
||||
code: "UNSUPPORTED_CONVERSION".to_string(),
|
||||
message: format!(
|
||||
"Conversion from {} to {} is not supported by engine {}",
|
||||
from, to, engine
|
||||
),
|
||||
suggestions: suggestions.into_iter().map(Into::into).collect(),
|
||||
}),
|
||||
}),
|
||||
Err(e) => Err(async_graphql::Error::new(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get suggestions for a conversion
|
||||
async fn suggestions(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
from: String,
|
||||
to: String,
|
||||
) -> GqlResult<Vec<GqlSuggestion>> {
|
||||
let _user = validate_auth(ctx)?;
|
||||
let registry = ctx.data::<Arc<EngineRegistry>>()?;
|
||||
|
||||
let suggestions = registry.find_suggestions(&from, &to);
|
||||
Ok(suggestions.into_iter().map(Into::into).collect())
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Mutation Root ============
|
||||
|
||||
pub struct MutationRoot;
|
||||
|
||||
#[Object]
|
||||
impl MutationRoot {
|
||||
/// Create a new conversion job
|
||||
///
|
||||
/// Note: File upload via GraphQL requires multipart form handling.
|
||||
/// For file uploads, the REST API endpoint is recommended.
|
||||
/// This mutation accepts base64-encoded file data for simpler integration.
|
||||
async fn create_job(
|
||||
&self,
|
||||
ctx: &Context<'_>,
|
||||
/// Original filename
|
||||
filename: String,
|
||||
/// Base64-encoded file content
|
||||
file_base64: String,
|
||||
/// Conversion input parameters
|
||||
input: CreateJobInput,
|
||||
) -> GqlResult<CreateJobResult> {
|
||||
let user = validate_auth(ctx)?;
|
||||
let service = ctx.data::<Arc<ConversionService>>()?;
|
||||
|
||||
// Decode base64 file content
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
let file_data = STANDARD.decode(&file_base64)
|
||||
.map_err(|e| async_graphql::Error::new(format!("Invalid base64 encoding: {}", e)))?;
|
||||
|
||||
// Parse options if provided
|
||||
let options = if let Some(opts_str) = input.options {
|
||||
Some(serde_json::from_str(&opts_str)
|
||||
.map_err(|e| async_graphql::Error::new(format!("Invalid options JSON: {}", e)))?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match service
|
||||
.create_job(
|
||||
user.user_id,
|
||||
filename,
|
||||
input.engine,
|
||||
input.target_format,
|
||||
options,
|
||||
file_data,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(job) => Ok(CreateJobResult {
|
||||
success: true,
|
||||
job: Some(GqlJob {
|
||||
id: ID(job.id.to_string()),
|
||||
original_filename: job.original_filename,
|
||||
source_format: job.source_format,
|
||||
target_format: job.target_format,
|
||||
engine: job.engine,
|
||||
status: job.status.into(),
|
||||
output_filename: job.output_filename,
|
||||
error_message: job.error_message,
|
||||
download_url: None,
|
||||
created_at: job.created_at,
|
||||
completed_at: job.completed_at,
|
||||
}),
|
||||
error: None,
|
||||
}),
|
||||
Err(crate::error::ApiError::UnsupportedConversion {
|
||||
engine,
|
||||
from,
|
||||
to,
|
||||
suggestions,
|
||||
}) => Ok(CreateJobResult {
|
||||
success: false,
|
||||
job: None,
|
||||
error: Some(GqlConversionError {
|
||||
code: "UNSUPPORTED_CONVERSION".to_string(),
|
||||
message: format!(
|
||||
"Conversion from {} to {} is not supported by engine {}",
|
||||
from, to, engine
|
||||
),
|
||||
suggestions: suggestions.into_iter().map(Into::into).collect(),
|
||||
}),
|
||||
}),
|
||||
Err(e) => Ok(CreateJobResult {
|
||||
success: false,
|
||||
job: None,
|
||||
error: Some(GqlConversionError {
|
||||
code: "ERROR".to_string(),
|
||||
message: e.to_string(),
|
||||
suggestions: vec![],
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a job
|
||||
async fn delete_job(&self, ctx: &Context<'_>, id: ID) -> GqlResult<bool> {
|
||||
let user = validate_auth(ctx)?;
|
||||
let service = ctx.data::<Arc<ConversionService>>()?;
|
||||
|
||||
let job_id = Uuid::parse_str(&id.0)
|
||||
.map_err(|_| async_graphql::Error::new("Invalid job ID format"))?;
|
||||
|
||||
service
|
||||
.delete_job(job_id, &user.user_id)
|
||||
.await
|
||||
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_job_status_conversion() {
|
||||
assert_eq!(GqlJobStatus::from(JobStatus::Pending), GqlJobStatus::Pending);
|
||||
assert_eq!(GqlJobStatus::from(JobStatus::Processing), GqlJobStatus::Processing);
|
||||
assert_eq!(GqlJobStatus::from(JobStatus::Completed), GqlJobStatus::Completed);
|
||||
assert_eq!(GqlJobStatus::from(JobStatus::Failed), GqlJobStatus::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_routes_are_defined() {
|
||||
let _routes = routes();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue