hammerwork_web/api/
mod.rs

1//! REST API endpoints for the Hammerwork web dashboard.
2//!
3//! This module provides a comprehensive REST API for job queue management,
4//! including endpoints for jobs, queues, statistics, and system information.
5//! All API responses use a standardized format with proper error handling.
6//!
7//! # API Response Format
8//!
9//! All API endpoints return responses in a consistent format:
10//!
11//! ```rust
12//! use hammerwork_web::api::ApiResponse;
13//! use serde_json::json;
14//!
15//! // Success response
16//! let success_response = ApiResponse::success(json!({"count": 42}));
17//! assert!(success_response.success);
18//! assert!(success_response.data.is_some());
19//! assert!(success_response.error.is_none());
20//!
21//! // Error response
22//! let error_response: ApiResponse<()> = ApiResponse::error("Something went wrong".to_string());
23//! assert!(!error_response.success);
24//! assert!(error_response.data.is_none());
25//! assert!(error_response.error.is_some());
26//! ```
27//!
28//! # Pagination
29//!
30//! Many endpoints support pagination using query parameters:
31//!
32//! ```rust
33//! use hammerwork_web::api::{PaginationParams, PaginationMeta};
34//!
35//! let params = PaginationParams {
36//!     page: Some(2),
37//!     limit: Some(50),
38//!     offset: None,
39//! };
40//!
41//! assert_eq!(params.get_limit(), 50);
42//! assert_eq!(params.get_offset(), 50); // (page-1) * limit
43//!
44//! let meta = PaginationMeta::new(&params, 200); // 200 total items
45//! assert_eq!(meta.page, 2);
46//! assert_eq!(meta.total_pages, 4);
47//! assert!(meta.has_next);
48//! assert!(meta.has_prev);
49//! ```
50
51pub mod jobs;
52pub mod queues;
53pub mod stats;
54pub mod system;
55
56use serde::{Deserialize, Serialize};
57
58/// Standard API response wrapper
59#[derive(Debug, Serialize)]
60pub struct ApiResponse<T> {
61    pub success: bool,
62    pub data: Option<T>,
63    pub error: Option<String>,
64    pub timestamp: chrono::DateTime<chrono::Utc>,
65}
66
67impl<T> ApiResponse<T> {
68    pub fn success(data: T) -> Self {
69        Self {
70            success: true,
71            data: Some(data),
72            error: None,
73            timestamp: chrono::Utc::now(),
74        }
75    }
76
77    pub fn error(message: String) -> Self {
78        Self {
79            success: false,
80            data: None,
81            error: Some(message),
82            timestamp: chrono::Utc::now(),
83        }
84    }
85}
86
87/// Pagination parameters
88#[derive(Debug, Deserialize)]
89pub struct PaginationParams {
90    pub page: Option<u32>,
91    pub limit: Option<u32>,
92    pub offset: Option<u32>,
93}
94
95impl Default for PaginationParams {
96    fn default() -> Self {
97        Self {
98            page: Some(1),
99            limit: Some(50),
100            offset: None,
101        }
102    }
103}
104
105impl PaginationParams {
106    pub fn get_offset(&self) -> u32 {
107        if let Some(offset) = self.offset {
108            offset
109        } else {
110            let page = self.page.unwrap_or(1);
111            let limit = self.limit.unwrap_or(50);
112            (page.saturating_sub(1)) * limit
113        }
114    }
115
116    pub fn get_limit(&self) -> u32 {
117        self.limit.unwrap_or(50).min(1000) // Cap at 1000 items
118    }
119}
120
121/// Pagination metadata for responses
122#[derive(Debug, Serialize)]
123pub struct PaginationMeta {
124    pub page: u32,
125    pub limit: u32,
126    pub offset: u32,
127    pub total: u64,
128    pub total_pages: u32,
129    pub has_next: bool,
130    pub has_prev: bool,
131}
132
133impl PaginationMeta {
134    pub fn new(params: &PaginationParams, total: u64) -> Self {
135        let limit = params.get_limit();
136        let offset = params.get_offset();
137        let page = params.page.unwrap_or(1);
138        let total_pages = ((total as f64) / (limit as f64)).ceil() as u32;
139
140        Self {
141            page,
142            limit,
143            offset,
144            total,
145            total_pages,
146            has_next: page < total_pages,
147            has_prev: page > 1,
148        }
149    }
150}
151
152/// Paginated response wrapper
153#[derive(Debug, Serialize)]
154pub struct PaginatedResponse<T> {
155    pub items: Vec<T>,
156    pub pagination: PaginationMeta,
157}
158
159/// Query filter parameters
160#[derive(Debug, Deserialize, Default)]
161pub struct FilterParams {
162    pub status: Option<String>,
163    pub priority: Option<String>,
164    pub queue: Option<String>,
165    pub created_after: Option<chrono::DateTime<chrono::Utc>>,
166    pub created_before: Option<chrono::DateTime<chrono::Utc>>,
167    pub search: Option<String>,
168}
169
170/// Sort parameters
171#[derive(Debug, Deserialize, Default)]
172pub struct SortParams {
173    pub sort_by: Option<String>,
174    pub sort_order: Option<String>,
175}
176
177impl SortParams {
178    pub fn get_order_by(&self) -> (String, String) {
179        let field = self.sort_by.as_deref().unwrap_or("created_at").to_string();
180        let direction = match self.sort_order.as_deref() {
181            Some("asc") | Some("ASC") => "ASC".to_string(),
182            _ => "DESC".to_string(),
183        };
184        (field, direction)
185    }
186}
187
188/// Common error handling for API endpoints
189pub async fn handle_api_error(
190    err: warp::Rejection,
191) -> Result<impl warp::Reply, std::convert::Infallible> {
192    let response = if err.is_not_found() {
193        ApiResponse::<()>::error("Resource not found".to_string())
194    } else if err
195        .find::<warp::filters::body::BodyDeserializeError>()
196        .is_some()
197    {
198        ApiResponse::<()>::error("Invalid request body".to_string())
199    } else if err.find::<warp::reject::InvalidQuery>().is_some() {
200        ApiResponse::<()>::error("Invalid query parameters".to_string())
201    } else {
202        ApiResponse::<()>::error("Internal server error".to_string())
203    };
204
205    let status = if err.is_not_found() {
206        warp::http::StatusCode::NOT_FOUND
207    } else if err
208        .find::<warp::filters::body::BodyDeserializeError>()
209        .is_some()
210        || err.find::<warp::reject::InvalidQuery>().is_some()
211    {
212        warp::http::StatusCode::BAD_REQUEST
213    } else {
214        warp::http::StatusCode::INTERNAL_SERVER_ERROR
215    };
216
217    Ok(warp::reply::with_status(
218        warp::reply::json(&response),
219        status,
220    ))
221}
222
223/// Extract pagination parameters from query string
224pub fn with_pagination()
225-> impl warp::Filter<Extract = (PaginationParams,), Error = warp::Rejection> + Clone {
226    warp::query::<PaginationParams>()
227}
228
229/// Extract filter parameters from query string  
230pub fn with_filters()
231-> impl warp::Filter<Extract = (FilterParams,), Error = warp::Rejection> + Clone {
232    warp::query::<FilterParams>()
233}
234
235/// Extract sort parameters from query string
236pub fn with_sort() -> impl warp::Filter<Extract = (SortParams,), Error = warp::Rejection> + Clone {
237    warp::query::<SortParams>()
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn test_api_response_success() {
246        let response = ApiResponse::success("test data");
247        assert!(response.success);
248        assert_eq!(response.data, Some("test data"));
249        assert!(response.error.is_none());
250    }
251
252    #[test]
253    fn test_api_response_error() {
254        let response: ApiResponse<()> = ApiResponse::error("Something went wrong".to_string());
255        assert!(!response.success);
256        assert!(response.data.is_none());
257        assert_eq!(response.error, Some("Something went wrong".to_string()));
258    }
259
260    #[test]
261    fn test_pagination_params_defaults() {
262        let params = PaginationParams::default();
263        assert_eq!(params.get_limit(), 50);
264        assert_eq!(params.get_offset(), 0);
265    }
266
267    #[test]
268    fn test_pagination_params_calculation() {
269        let params = PaginationParams {
270            page: Some(3),
271            limit: Some(20),
272            offset: None,
273        };
274        assert_eq!(params.get_limit(), 20);
275        assert_eq!(params.get_offset(), 40); // (3-1) * 20
276    }
277
278    #[test]
279    fn test_pagination_meta() {
280        let params = PaginationParams {
281            page: Some(2),
282            limit: Some(10),
283            offset: None,
284        };
285        let meta = PaginationMeta::new(&params, 45);
286
287        assert_eq!(meta.page, 2);
288        assert_eq!(meta.limit, 10);
289        assert_eq!(meta.total, 45);
290        assert_eq!(meta.total_pages, 5);
291        assert!(meta.has_next);
292        assert!(meta.has_prev);
293    }
294
295    #[test]
296    fn test_sort_params_defaults() {
297        let params = SortParams {
298            sort_by: None,
299            sort_order: None,
300        };
301        let (field, direction) = params.get_order_by();
302        assert_eq!(field, "created_at");
303        assert_eq!(direction, "DESC");
304    }
305
306    #[test]
307    fn test_sort_params_custom() {
308        let params = SortParams {
309            sort_by: Some("name".to_string()),
310            sort_order: Some("asc".to_string()),
311        };
312        let (field, direction) = params.get_order_by();
313        assert_eq!(field, "name");
314        assert_eq!(direction, "ASC");
315    }
316}