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
202
api-server/src/error.rs
Normal file
202
api-server/src/error.rs
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
//! Error handling module with structured error responses and suggestions
|
||||
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Suggestion for alternative conversion options
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConversionSuggestion {
|
||||
/// Suggested engine to use
|
||||
pub engine: String,
|
||||
/// Source format
|
||||
pub from: String,
|
||||
/// Target format
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
/// Structured error response
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub error: ErrorDetail,
|
||||
}
|
||||
|
||||
/// Error detail with code, message, and optional suggestions
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ErrorDetail {
|
||||
/// Error code for programmatic handling
|
||||
pub code: String,
|
||||
/// Human-readable error message
|
||||
pub message: String,
|
||||
/// Optional suggestions for resolving the error
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub suggestions: Option<Vec<ConversionSuggestion>>,
|
||||
}
|
||||
|
||||
/// API Error types
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ApiError {
|
||||
#[error("Unauthorized: {0}")]
|
||||
Unauthorized(String),
|
||||
|
||||
#[error("Invalid token: {0}")]
|
||||
InvalidToken(String),
|
||||
|
||||
#[error("Token expired")]
|
||||
TokenExpired,
|
||||
|
||||
#[error("Missing authorization header")]
|
||||
MissingAuthHeader,
|
||||
|
||||
#[error("Invalid file: {0}")]
|
||||
InvalidFile(String),
|
||||
|
||||
#[error("Engine not found: {0}")]
|
||||
EngineNotFound(String),
|
||||
|
||||
#[error("Unsupported conversion from {from} to {to} using engine {engine}")]
|
||||
UnsupportedConversion {
|
||||
engine: String,
|
||||
from: String,
|
||||
to: String,
|
||||
suggestions: Vec<ConversionSuggestion>,
|
||||
},
|
||||
|
||||
#[error("Conversion failed: {0}")]
|
||||
ConversionFailed(String),
|
||||
|
||||
#[error("Job not found: {0}")]
|
||||
JobNotFound(String),
|
||||
|
||||
#[error("File not found: {0}")]
|
||||
FileNotFound(String),
|
||||
|
||||
#[error("Invalid request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("Internal server error: {0}")]
|
||||
InternalError(String),
|
||||
|
||||
#[error("File too large: max size is {max_size} bytes")]
|
||||
FileTooLarge { max_size: usize },
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
/// Get the error code string
|
||||
pub fn code(&self) -> &'static str {
|
||||
match self {
|
||||
ApiError::Unauthorized(_) => "UNAUTHORIZED",
|
||||
ApiError::InvalidToken(_) => "INVALID_TOKEN",
|
||||
ApiError::TokenExpired => "TOKEN_EXPIRED",
|
||||
ApiError::MissingAuthHeader => "MISSING_AUTH_HEADER",
|
||||
ApiError::InvalidFile(_) => "INVALID_FILE",
|
||||
ApiError::EngineNotFound(_) => "ENGINE_NOT_FOUND",
|
||||
ApiError::UnsupportedConversion { .. } => "UNSUPPORTED_CONVERSION",
|
||||
ApiError::ConversionFailed(_) => "CONVERSION_FAILED",
|
||||
ApiError::JobNotFound(_) => "JOB_NOT_FOUND",
|
||||
ApiError::FileNotFound(_) => "FILE_NOT_FOUND",
|
||||
ApiError::BadRequest(_) => "BAD_REQUEST",
|
||||
ApiError::InternalError(_) => "INTERNAL_ERROR",
|
||||
ApiError::FileTooLarge { .. } => "FILE_TOO_LARGE",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get HTTP status code
|
||||
pub fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
ApiError::Unauthorized(_)
|
||||
| ApiError::InvalidToken(_)
|
||||
| ApiError::TokenExpired
|
||||
| ApiError::MissingAuthHeader => StatusCode::UNAUTHORIZED,
|
||||
|
||||
ApiError::InvalidFile(_)
|
||||
| ApiError::BadRequest(_)
|
||||
| ApiError::FileTooLarge { .. } => StatusCode::BAD_REQUEST,
|
||||
|
||||
ApiError::EngineNotFound(_)
|
||||
| ApiError::JobNotFound(_)
|
||||
| ApiError::FileNotFound(_) => StatusCode::NOT_FOUND,
|
||||
|
||||
ApiError::UnsupportedConversion { .. } => StatusCode::UNPROCESSABLE_ENTITY,
|
||||
|
||||
ApiError::ConversionFailed(_)
|
||||
| ApiError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get suggestions if available
|
||||
pub fn suggestions(&self) -> Option<Vec<ConversionSuggestion>> {
|
||||
match self {
|
||||
ApiError::UnsupportedConversion { suggestions, .. } => Some(suggestions.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to ErrorResponse
|
||||
pub fn to_error_response(&self) -> ErrorResponse {
|
||||
ErrorResponse {
|
||||
error: ErrorDetail {
|
||||
code: self.code().to_string(),
|
||||
message: self.to_string(),
|
||||
suggestions: self.suggestions(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = self.status_code();
|
||||
let body = Json(self.to_error_response());
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// Result type alias for API operations
|
||||
pub type ApiResult<T> = Result<T, ApiError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_error_codes() {
|
||||
assert_eq!(ApiError::Unauthorized("test".into()).code(), "UNAUTHORIZED");
|
||||
assert_eq!(ApiError::TokenExpired.code(), "TOKEN_EXPIRED");
|
||||
assert_eq!(
|
||||
ApiError::UnsupportedConversion {
|
||||
engine: "test".into(),
|
||||
from: "pdf".into(),
|
||||
to: "xyz".into(),
|
||||
suggestions: vec![],
|
||||
}
|
||||
.code(),
|
||||
"UNSUPPORTED_CONVERSION"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_response_serialization() {
|
||||
let error = ApiError::UnsupportedConversion {
|
||||
engine: "ffmpeg".into(),
|
||||
from: "pdf".into(),
|
||||
to: "mp4".into(),
|
||||
suggestions: vec![ConversionSuggestion {
|
||||
engine: "libreoffice".into(),
|
||||
from: "pdf".into(),
|
||||
to: "docx".into(),
|
||||
}],
|
||||
};
|
||||
|
||||
let response = error.to_error_response();
|
||||
let json = serde_json::to_string(&response).unwrap();
|
||||
|
||||
assert!(json.contains("UNSUPPORTED_CONVERSION"));
|
||||
assert!(json.contains("suggestions"));
|
||||
assert!(json.contains("libreoffice"));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue