1macro_rules! try_api {
54 ($expr:expr, $context:expr) => {
55 match $expr {
56 Ok(value) => value,
57 Err(e) => return Ok($crate::api::internal_error($context, &e)),
58 }
59 };
60}
61
62pub mod archive;
63pub mod history;
64pub mod jobs;
65pub mod queues;
66pub mod stats;
67pub mod system;
68
69use serde::{Deserialize, Serialize};
70use warp::Reply;
71
72#[derive(Debug, Serialize)]
74pub struct ApiResponse<T> {
75 pub success: bool,
76 pub data: Option<T>,
77 pub error: Option<String>,
78 pub timestamp: chrono::DateTime<chrono::Utc>,
79}
80
81impl<T> ApiResponse<T> {
82 pub fn success(data: T) -> Self {
83 Self {
84 success: true,
85 data: Some(data),
86 error: None,
87 timestamp: chrono::Utc::now(),
88 }
89 }
90
91 pub fn error(message: String) -> Self {
92 Self {
93 success: false,
94 data: None,
95 error: Some(message),
96 timestamp: chrono::Utc::now(),
97 }
98 }
99}
100
101pub fn json_reply<T: Serialize>(body: &T) -> warp::reply::Response {
103 warp::reply::with_status(warp::reply::json(body), warp::http::StatusCode::OK).into_response()
104}
105
106pub fn error_reply(
108 status: warp::http::StatusCode,
109 message: impl Into<String>,
110) -> warp::reply::Response {
111 let body = ApiResponse::<()>::error(message.into());
112 warp::reply::with_status(warp::reply::json(&body), status).into_response()
113}
114
115pub fn internal_error(context: &str, err: &dyn std::fmt::Display) -> warp::reply::Response {
121 tracing::error!(error = %err, "{}", context);
122 error_reply(
123 warp::http::StatusCode::INTERNAL_SERVER_ERROR,
124 format!("{}: {}", context, err),
125 )
126}
127
128pub fn queue_error_reply(
131 context: &str,
132 err: &hammerwork::HammerworkError,
133) -> warp::reply::Response {
134 use hammerwork::HammerworkError;
135 match err {
136 HammerworkError::JobNotFound { .. } => error_reply(
137 warp::http::StatusCode::NOT_FOUND,
138 format!("{}: {}", context, err),
139 ),
140 HammerworkError::InvalidJobTransition { .. } => error_reply(
141 warp::http::StatusCode::CONFLICT,
142 format!("{}: {}", context, err),
143 ),
144 _ => internal_error(context, err),
145 }
146}
147
148#[derive(Debug, Deserialize)]
150pub struct PaginationParams {
151 pub page: Option<u32>,
152 pub limit: Option<u32>,
153 pub offset: Option<u32>,
154}
155
156impl Default for PaginationParams {
157 fn default() -> Self {
158 Self {
159 page: Some(1),
160 limit: Some(50),
161 offset: None,
162 }
163 }
164}
165
166pub const DEFAULT_PAGE_SIZE: u32 = 50;
168
169pub const MAX_PAGE_SIZE: u32 = 1000;
171
172impl PaginationParams {
173 pub fn get_offset(&self) -> u32 {
176 if let Some(offset) = self.offset {
177 offset
178 } else {
179 let page = self.page.unwrap_or(1);
180 page.saturating_sub(1).saturating_mul(self.get_limit())
181 }
182 }
183
184 pub fn get_limit(&self) -> u32 {
186 self.limit
187 .unwrap_or(DEFAULT_PAGE_SIZE)
188 .clamp(1, MAX_PAGE_SIZE)
189 }
190}
191
192#[derive(Debug, Serialize)]
194pub struct PaginationMeta {
195 pub page: u32,
196 pub limit: u32,
197 pub offset: u32,
198 pub total: u64,
199 pub total_pages: u32,
200 pub has_next: bool,
201 pub has_prev: bool,
202}
203
204impl PaginationMeta {
205 pub fn new(params: &PaginationParams, total: u64) -> Self {
206 let limit = params.get_limit();
207 let offset = params.get_offset();
208 let page = params.page.unwrap_or(1);
209 let total_pages = u32::try_from(total.div_ceil(u64::from(limit))).unwrap_or(u32::MAX);
210
211 Self {
212 page,
213 limit,
214 offset,
215 total,
216 total_pages,
217 has_next: page < total_pages,
218 has_prev: page > 1,
219 }
220 }
221}
222
223#[derive(Debug, Serialize)]
225pub struct PaginatedResponse<T> {
226 pub items: Vec<T>,
227 pub pagination: PaginationMeta,
228}
229
230#[derive(Debug, Deserialize, Default)]
232pub struct FilterParams {
233 pub status: Option<String>,
234 pub priority: Option<String>,
235 pub queue: Option<String>,
236 pub created_after: Option<chrono::DateTime<chrono::Utc>>,
237 pub created_before: Option<chrono::DateTime<chrono::Utc>>,
238 pub search: Option<String>,
239}
240
241#[derive(Debug, Deserialize, Default)]
243pub struct SortParams {
244 pub sort_by: Option<String>,
245 pub sort_order: Option<String>,
246}
247
248impl SortParams {
249 pub fn is_ascending(&self) -> bool {
252 self.sort_order
253 .as_deref()
254 .is_some_and(|order| order.trim().eq_ignore_ascii_case("asc"))
255 }
256
257 pub fn get_order_by(&self) -> (String, String) {
258 let field = self.sort_by.as_deref().unwrap_or("created_at").to_string();
259 let direction = if self.is_ascending() { "ASC" } else { "DESC" }.to_string();
260 (field, direction)
261 }
262}
263
264pub async fn handle_api_error(
266 err: warp::Rejection,
267) -> Result<impl warp::Reply, std::convert::Infallible> {
268 let response = if err.is_not_found() {
269 ApiResponse::<()>::error("Resource not found".to_string())
270 } else if err
271 .find::<warp::filters::body::BodyDeserializeError>()
272 .is_some()
273 {
274 ApiResponse::<()>::error("Invalid request body".to_string())
275 } else if err.find::<warp::reject::InvalidQuery>().is_some() {
276 ApiResponse::<()>::error("Invalid query parameters".to_string())
277 } else {
278 ApiResponse::<()>::error("Internal server error".to_string())
279 };
280
281 let status = if err.is_not_found() {
282 warp::http::StatusCode::NOT_FOUND
283 } else if err
284 .find::<warp::filters::body::BodyDeserializeError>()
285 .is_some()
286 || err.find::<warp::reject::InvalidQuery>().is_some()
287 {
288 warp::http::StatusCode::BAD_REQUEST
289 } else {
290 warp::http::StatusCode::INTERNAL_SERVER_ERROR
291 };
292
293 Ok(warp::reply::with_status(
294 warp::reply::json(&response),
295 status,
296 ))
297}
298
299pub fn with_pagination()
301-> impl warp::Filter<Extract = (PaginationParams,), Error = warp::Rejection> + Clone {
302 warp::query::<PaginationParams>()
303}
304
305pub fn with_filters()
307-> impl warp::Filter<Extract = (FilterParams,), Error = warp::Rejection> + Clone {
308 warp::query::<FilterParams>()
309}
310
311pub fn with_sort() -> impl warp::Filter<Extract = (SortParams,), Error = warp::Rejection> + Clone {
313 warp::query::<SortParams>()
314}
315
316#[cfg(test)]
318pub(crate) mod test_support {
319 use hammerwork::JobQueue;
320 use std::sync::Arc;
321
322 pub fn unreachable_queue() -> Arc<JobQueue<sqlx::Postgres>> {
324 let pool = sqlx::postgres::PgPoolOptions::new()
325 .acquire_timeout(std::time::Duration::from_millis(200))
326 .connect_lazy("postgres://nobody:nothing@127.0.0.1:1/none")
327 .expect("lazy pool");
328 Arc::new(JobQueue::new(pool))
329 }
330
331 pub async fn body_json(response: warp::reply::Response) -> (u16, serde_json::Value) {
333 use warp::Filter;
334 let slot = Arc::new(std::sync::Mutex::new(Some(response)));
336 let filter = warp::any().map(move || slot.lock().unwrap().take().unwrap());
337 let reply = warp::test::request().reply(&filter).await;
338 (
339 reply.status().as_u16(),
340 serde_json::from_slice(reply.body()).unwrap(),
341 )
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::test_support::body_json;
348 use super::*;
349
350 #[test]
351 fn test_api_response_success() {
352 let response = ApiResponse::success("test data");
353 assert!(response.success);
354 assert_eq!(response.data, Some("test data"));
355 assert!(response.error.is_none());
356 }
357
358 #[test]
359 fn test_api_response_error() {
360 let response: ApiResponse<()> = ApiResponse::error("Something went wrong".to_string());
361 assert!(!response.success);
362 assert!(response.data.is_none());
363 assert_eq!(response.error, Some("Something went wrong".to_string()));
364 }
365
366 #[test]
367 fn test_pagination_params_defaults() {
368 let params = PaginationParams::default();
369 assert_eq!(params.get_limit(), 50);
370 assert_eq!(params.get_offset(), 0);
371 }
372
373 #[test]
374 fn test_pagination_params_calculation() {
375 let params = PaginationParams {
376 page: Some(3),
377 limit: Some(20),
378 offset: None,
379 };
380 assert_eq!(params.get_limit(), 20);
381 assert_eq!(params.get_offset(), 40); }
383
384 #[test]
385 fn the_offset_uses_the_clamped_limit_and_never_overflows() {
386 let params = PaginationParams {
388 page: Some(2),
389 limit: Some(5000),
390 offset: None,
391 };
392 assert_eq!(params.get_limit(), MAX_PAGE_SIZE);
393 assert_eq!(params.get_offset(), MAX_PAGE_SIZE);
394
395 let huge = PaginationParams {
396 page: Some(u32::MAX),
397 limit: Some(u32::MAX),
398 offset: None,
399 };
400 assert_eq!(
401 huge.get_offset(),
402 u32::MAX,
403 "saturates instead of overflowing"
404 );
405
406 let zero = PaginationParams {
407 page: Some(0),
408 limit: Some(0),
409 offset: None,
410 };
411 assert_eq!(zero.get_limit(), 1, "a zero limit is clamped to one");
412 assert_eq!(zero.get_offset(), 0);
413 let meta = PaginationMeta::new(&zero, 3);
414 assert_eq!(meta.total_pages, 3);
415
416 let explicit = PaginationParams {
417 page: Some(7),
418 limit: Some(10),
419 offset: Some(5),
420 };
421 assert_eq!(explicit.get_offset(), 5, "an explicit offset wins");
422 }
423
424 #[test]
425 fn test_pagination_meta() {
426 let params = PaginationParams {
427 page: Some(2),
428 limit: Some(10),
429 offset: None,
430 };
431 let meta = PaginationMeta::new(¶ms, 45);
432
433 assert_eq!(meta.page, 2);
434 assert_eq!(meta.limit, 10);
435 assert_eq!(meta.total, 45);
436 assert_eq!(meta.total_pages, 5);
437 assert!(meta.has_next);
438 assert!(meta.has_prev);
439 }
440
441 #[test]
442 fn test_sort_params_defaults() {
443 let params = SortParams {
444 sort_by: None,
445 sort_order: None,
446 };
447 let (field, direction) = params.get_order_by();
448 assert_eq!(field, "created_at");
449 assert_eq!(direction, "DESC");
450 }
451
452 #[test]
455 fn sort_order_is_case_insensitive() {
456 let order = |order: Option<&str>| SortParams {
457 sort_by: Some("created_at".to_string()),
458 sort_order: order.map(str::to_string),
459 };
460 for ascending in ["asc", "ASC", "Asc", " asc "] {
461 assert!(order(Some(ascending)).is_ascending(), "{ascending}");
462 assert_eq!(order(Some(ascending)).get_order_by().1, "ASC");
463 }
464 for descending in [None, Some("desc"), Some("DESC"), Some("ascending")] {
465 assert!(!order(descending).is_ascending(), "{descending:?}");
466 assert_eq!(order(descending).get_order_by().1, "DESC");
467 }
468 }
469
470 #[test]
471 fn test_sort_params_custom() {
472 let params = SortParams {
473 sort_by: Some("name".to_string()),
474 sort_order: Some("asc".to_string()),
475 };
476 let (field, direction) = params.get_order_by();
477 assert_eq!(field, "name");
478 assert_eq!(direction, "ASC");
479 }
480
481 #[tokio::test]
482 async fn test_internal_error_is_500_with_json_error_body() {
483 let (status, body) = body_json(internal_error("Failed to list jobs", &"db down")).await;
484 assert_eq!(status, 500);
485 assert_eq!(body["success"], false);
486 assert_eq!(body["error"], "Failed to list jobs: db down");
487 assert!(body["data"].is_null());
488 }
489
490 #[tokio::test]
491 async fn test_error_reply_uses_given_status() {
492 let (status, body) =
493 body_json(error_reply(warp::http::StatusCode::BAD_REQUEST, "nope")).await;
494 assert_eq!(status, 400);
495 assert_eq!(body["error"], "nope");
496 }
497
498 #[tokio::test]
499 async fn test_json_reply_is_200() {
500 let (status, body) = body_json(json_reply(&ApiResponse::success(5))).await;
501 assert_eq!(status, 200);
502 assert_eq!(body["data"], 5);
503 }
504}
505
506#[cfg(test)]
510mod undecodable_row_tests {
511 use super::history::JobHistory;
512 use hammerwork::archive::{ArchivalConfig, ArchivalPolicy, ArchivalReason};
513 use hammerwork::{Job, JobId};
514 use serde_json::{Value, json};
515 use std::future::Future;
516 use std::sync::Arc;
517 use warp::Filter;
518
519 fn listed(body: &Value) -> Vec<String> {
521 let mut ids: Vec<String> = body["data"]["items"]
522 .as_array()
523 .unwrap_or_else(|| panic!("no items: {body}"))
524 .iter()
525 .map(|item| item["id"].as_str().unwrap().to_string())
526 .collect();
527 ids.sort();
528 ids
529 }
530
531 async fn listings_skip_undecodable_rows<Q, S, F>(
534 queue: Arc<Q>,
535 sql: S,
536 corrupt_archive_id: bool,
537 ) where
538 Q: JobHistory + 'static,
539 S: Fn(String) -> F,
540 F: Future<Output = ()>,
541 {
542 let routes = super::jobs::routes(queue.clone())
543 .or(super::queues::routes(queue.clone()))
544 .or(super::archive::archive_routes(queue.clone()));
545 let get = |path: String| {
546 let routes = routes.clone();
547 async move {
548 let response = warp::test::request().path(&path).reply(&routes).await;
549 let body: Value = serde_json::from_slice(response.body()).unwrap();
550 (response.status().as_u16(), body)
551 }
552 };
553
554 let tag = uuid::Uuid::new_v4().simple().to_string();
555 let name = format!("undecodable_{tag}");
556 let archive_name = format!("undecodable_archive_{tag}");
557 let enqueue = |queue_name: &str| queue.enqueue(Job::new(queue_name.to_string(), json!({})));
558 let break_timeout = |id: JobId| {
559 sql(format!(
560 "UPDATE hammerwork_jobs SET timeout_seconds = -1 WHERE id = '{id}'"
561 ))
562 };
563
564 let ready = enqueue(&name).await.unwrap();
565 let bad_ready = enqueue(&name).await.unwrap();
566 break_timeout(bad_ready).await;
567 let dead = enqueue(&name).await.unwrap();
568 let bad_dead = enqueue(&name).await.unwrap();
569 for id in [dead, bad_dead] {
570 queue.mark_job_dead(id, "gone").await.unwrap();
571 }
572 break_timeout(bad_dead).await;
573 let mut healthy = vec![ready.to_string(), dead.to_string()];
574 healthy.sort();
575
576 for path in [
578 format!("/jobs?queue={name}&limit=100"),
579 format!("/queues/{name}/jobs?limit=100"),
580 ] {
581 let (status, body) = get(path.clone()).await;
582 assert_eq!(status, 200, "{path}: {body}");
583 assert_eq!(listed(&body), healthy, "{path}");
584 }
585 let response = warp::test::request()
586 .method("POST")
587 .path("/jobs/search?limit=100")
588 .json(&json!({"query": "", "queues": [name]}))
589 .reply(&routes)
590 .await;
591 let body: Value = serde_json::from_slice(response.body()).unwrap();
592 assert_eq!(response.status(), 200, "{body}");
593 assert_eq!(listed(&body), healthy, "search");
594
595 let (status, body) = get(format!("/jobs/{bad_ready}")).await;
597 assert_eq!(status, 500, "{body}");
598 assert!(
599 body["error"].as_str().unwrap().contains("timeout_seconds"),
600 "{body}"
601 );
602
603 let archived = enqueue(&archive_name).await.unwrap();
605 let bad_archived = enqueue(&archive_name).await.unwrap();
606 for id in [archived, bad_archived] {
607 queue.mark_job_dead(id, "gone").await.unwrap();
608 }
609 let policy = ArchivalPolicy::new()
610 .archive_dead_after(chrono::Duration::seconds(0))
611 .enabled(true);
612 queue
613 .archive_jobs(
614 Some(&archive_name),
615 &policy,
616 &ArchivalConfig::new(),
617 ArchivalReason::Manual,
618 None,
619 )
620 .await
621 .unwrap();
622 if corrupt_archive_id {
623 sql(format!(
624 "UPDATE hammerwork_jobs_archive SET id = 'not-a-uuid' WHERE id = '{bad_archived}'"
625 ))
626 .await;
627 }
628 let (status, body) = get(format!("/archive/jobs?queue={archive_name}")).await;
629 assert_eq!(status, 200, "{body}");
630 let ids = listed(&body);
631 assert!(ids.contains(&archived.to_string()), "{body}");
632 if corrupt_archive_id {
633 assert_eq!(ids, [archived.to_string()], "{body}");
634 }
635
636 for (table, queue_name) in [
637 ("hammerwork_jobs", &name),
638 ("hammerwork_jobs_archive", &archive_name),
639 ] {
640 sql(format!(
641 "DELETE FROM {table} WHERE queue_name = '{queue_name}'"
642 ))
643 .await;
644 }
645 }
646
647 #[tokio::test]
648 #[ignore = "requires DATABASE_URL (PostgreSQL)"]
649 async fn postgres_listings_skip_undecodable_rows() {
650 let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
651 let pool = sqlx::PgPool::connect(&url).await.unwrap();
652 let queue = Arc::new(hammerwork::JobQueue::new(pool.clone()));
653 let sql = |statement: String| {
654 let pool = pool.clone();
655 async move {
656 sqlx::query(&statement).execute(&pool).await.unwrap();
657 }
658 };
659 listings_skip_undecodable_rows(queue, sql, false).await;
660 }
661
662 #[tokio::test]
663 #[ignore = "requires MYSQL_DATABASE_URL"]
664 async fn mysql_listings_skip_undecodable_rows() {
665 let url = std::env::var("MYSQL_DATABASE_URL").expect("MYSQL_DATABASE_URL");
666 let pool = sqlx::MySqlPool::connect(&url).await.unwrap();
667 let queue = Arc::new(hammerwork::JobQueue::new(pool.clone()));
668 let sql = |statement: String| {
669 let pool = pool.clone();
670 async move {
671 sqlx::query(&statement).execute(&pool).await.unwrap();
672 }
673 };
674 listings_skip_undecodable_rows(queue, sql, true).await;
675 }
676}