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