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