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
192pub(crate) fn failed_job_count(stats: &hammerwork::stats::QueueStats) -> u64 {
198 stats
199 .statistics
200 .failed
201 .saturating_add(stats.dead_count)
202 .saturating_add(stats.timed_out_count)
203}
204
205#[derive(Debug, Serialize)]
207pub struct PaginationMeta {
208 pub page: u32,
209 pub limit: u32,
210 pub offset: u32,
211 pub total: u64,
212 pub total_pages: u32,
213 pub has_next: bool,
214 pub has_prev: bool,
215}
216
217impl PaginationMeta {
218 pub fn new(params: &PaginationParams, total: u64) -> Self {
219 let limit = params.get_limit();
220 let offset = params.get_offset();
221 let page = match params.offset {
224 Some(offset) => (offset / limit).saturating_add(1),
225 None => params.page.unwrap_or(1),
226 };
227 let total_pages = u32::try_from(total.div_ceil(u64::from(limit))).unwrap_or(u32::MAX);
228
229 Self {
230 page,
231 limit,
232 offset,
233 total,
234 total_pages,
235 has_next: u64::from(offset) + u64::from(limit) < total,
236 has_prev: offset > 0,
237 }
238 }
239}
240
241#[derive(Debug, Serialize)]
243pub struct PaginatedResponse<T> {
244 pub items: Vec<T>,
245 pub pagination: PaginationMeta,
246}
247
248#[derive(Debug, Deserialize, Default)]
250pub struct FilterParams {
251 pub status: Option<String>,
252 pub priority: Option<String>,
253 pub queue: Option<String>,
254 pub created_after: Option<chrono::DateTime<chrono::Utc>>,
255 pub created_before: Option<chrono::DateTime<chrono::Utc>>,
256 pub search: Option<String>,
257}
258
259#[derive(Debug, Deserialize, Default)]
261pub struct SortParams {
262 pub sort_by: Option<String>,
263 pub sort_order: Option<String>,
264}
265
266impl SortParams {
267 pub fn is_ascending(&self) -> bool {
270 self.sort_order
271 .as_deref()
272 .is_some_and(|order| order.trim().eq_ignore_ascii_case("asc"))
273 }
274
275 pub fn get_order_by(&self) -> (String, String) {
276 let field = self.sort_by.as_deref().unwrap_or("created_at").to_string();
277 let direction = if self.is_ascending() { "ASC" } else { "DESC" }.to_string();
278 (field, direction)
279 }
280}
281
282pub async fn handle_api_error(
284 err: warp::Rejection,
285) -> Result<impl warp::Reply, std::convert::Infallible> {
286 let response = if err.is_not_found() {
287 ApiResponse::<()>::error("Resource not found".to_string())
288 } else if err
289 .find::<warp::filters::body::BodyDeserializeError>()
290 .is_some()
291 {
292 ApiResponse::<()>::error("Invalid request body".to_string())
293 } else if err.find::<warp::reject::InvalidQuery>().is_some() {
294 ApiResponse::<()>::error("Invalid query parameters".to_string())
295 } else {
296 ApiResponse::<()>::error("Internal server error".to_string())
297 };
298
299 let status = if err.is_not_found() {
300 warp::http::StatusCode::NOT_FOUND
301 } else if err
302 .find::<warp::filters::body::BodyDeserializeError>()
303 .is_some()
304 || err.find::<warp::reject::InvalidQuery>().is_some()
305 {
306 warp::http::StatusCode::BAD_REQUEST
307 } else {
308 warp::http::StatusCode::INTERNAL_SERVER_ERROR
309 };
310
311 Ok(warp::reply::with_status(
312 warp::reply::json(&response),
313 status,
314 ))
315}
316
317pub fn with_shared<T>(
319 value: T,
320) -> impl warp::Filter<Extract = (T,), Error = std::convert::Infallible> + Clone
321where
322 T: Clone + Send,
323{
324 use warp::Filter;
325 warp::any().map(move || value.clone())
326}
327
328pub fn with_pagination()
330-> impl warp::Filter<Extract = (PaginationParams,), Error = warp::Rejection> + Clone {
331 warp::query::<PaginationParams>()
332}
333
334pub fn with_filters()
336-> impl warp::Filter<Extract = (FilterParams,), Error = warp::Rejection> + Clone {
337 warp::query::<FilterParams>()
338}
339
340pub fn with_sort() -> impl warp::Filter<Extract = (SortParams,), Error = warp::Rejection> + Clone {
342 warp::query::<SortParams>()
343}
344
345#[cfg(test)]
347pub(crate) mod test_support {
348 use hammerwork::JobQueue;
349 use std::sync::Arc;
350
351 pub fn unreachable_queue() -> Arc<JobQueue<sqlx::Postgres>> {
353 let pool = sqlx::postgres::PgPoolOptions::new()
354 .acquire_timeout(std::time::Duration::from_millis(200))
355 .connect_lazy("postgres://nobody:nothing@127.0.0.1:1/none")
356 .expect("lazy pool");
357 Arc::new(JobQueue::new(pool))
358 }
359
360 impl super::history::JobHistory for hammerwork::queue::test::TestQueue {
363 async fn hourly_activity(
364 &self,
365 _queue: Option<&str>,
366 _start: chrono::DateTime<chrono::Utc>,
367 _end: chrono::DateTime<chrono::Utc>,
368 ) -> hammerwork::Result<Vec<super::history::HourBucket>> {
369 Ok(Vec::new())
370 }
371
372 async fn error_groups(
373 &self,
374 _since: chrono::DateTime<chrono::Utc>,
375 _limit: u32,
376 ) -> hammerwork::Result<Vec<super::history::ErrorGroup>> {
377 Ok(Vec::new())
378 }
379
380 async fn count_jobs(
381 &self,
382 _queue: Option<&str>,
383 _kind: super::history::FinishedKind,
384 _older_than: Option<chrono::DateTime<chrono::Utc>>,
385 ) -> hammerwork::Result<u64> {
386 Ok(0)
387 }
388
389 async fn delete_jobs(
390 &self,
391 _queue: Option<&str>,
392 _kind: super::history::FinishedKind,
393 _older_than: Option<chrono::DateTime<chrono::Utc>>,
394 ) -> hammerwork::Result<u64> {
395 Ok(0)
396 }
397
398 async fn archived_jobs(
399 &self,
400 _filter: &super::archive::ArchiveFilterParams,
401 _limit: u32,
402 _offset: u32,
403 ) -> hammerwork::Result<(Vec<hammerwork::archive::ArchivedJob>, u64)> {
404 Ok((Vec::new(), 0))
405 }
406
407 async fn database_now(&self) -> hammerwork::Result<chrono::DateTime<chrono::Utc>> {
408 Ok(self.clock().now())
409 }
410
411 async fn recent_job_changes(
412 &self,
413 _since: chrono::DateTime<chrono::Utc>,
414 _limit: u32,
415 ) -> hammerwork::Result<Vec<crate::websocket::JobUpdate>> {
416 Ok(Vec::new())
417 }
418
419 async fn table_maintenance(
420 &self,
421 _operation: super::history::TableMaintenance,
422 _tables: &[&'static str],
423 _dry_run: bool,
424 ) -> hammerwork::Result<Vec<super::history::MaintenanceStatement>> {
425 Ok(Vec::new())
426 }
427
428 async fn jobs_with_status(
429 &self,
430 queue: Option<&str>,
431 status: &str,
432 limit: u32,
433 ) -> hammerwork::Result<Vec<hammerwork::Job>> {
434 let mut jobs: Vec<_> = self
435 .get_all_jobs()
436 .await
437 .into_iter()
438 .filter(|job| job.status.as_str() == status)
439 .filter(|job| queue.is_none_or(|name| job.queue_name == name))
440 .collect();
441 jobs.sort_by_key(|job| std::cmp::Reverse(job.created_at));
442 jobs.truncate(limit as usize);
443 Ok(jobs)
444 }
445 }
446
447 pub async fn body_json(response: warp::reply::Response) -> (u16, serde_json::Value) {
449 use warp::Filter;
450 let slot = Arc::new(std::sync::Mutex::new(Some(response)));
452 let filter = warp::any().map(move || slot.lock().unwrap().take().unwrap());
453 let reply = warp::test::request().reply(&filter).await;
454 (
455 reply.status().as_u16(),
456 serde_json::from_slice(reply.body()).unwrap(),
457 )
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::test_support::body_json;
464 use super::*;
465
466 #[test]
467 fn test_api_response_success() {
468 let response = ApiResponse::success("test data");
469 assert!(response.success);
470 assert_eq!(response.data, Some("test data"));
471 assert!(response.error.is_none());
472 }
473
474 #[test]
475 fn test_api_response_error() {
476 let response: ApiResponse<()> = ApiResponse::error("Something went wrong".to_string());
477 assert!(!response.success);
478 assert!(response.data.is_none());
479 assert_eq!(response.error, Some("Something went wrong".to_string()));
480 }
481
482 #[test]
483 fn test_pagination_params_defaults() {
484 let params = PaginationParams::default();
485 assert_eq!(params.get_limit(), 50);
486 assert_eq!(params.get_offset(), 0);
487 }
488
489 #[test]
490 fn test_pagination_params_calculation() {
491 let params = PaginationParams {
492 page: Some(3),
493 limit: Some(20),
494 offset: None,
495 };
496 assert_eq!(params.get_limit(), 20);
497 assert_eq!(params.get_offset(), 40); }
499
500 #[test]
501 fn the_offset_uses_the_clamped_limit_and_never_overflows() {
502 let params = PaginationParams {
504 page: Some(2),
505 limit: Some(5000),
506 offset: None,
507 };
508 assert_eq!(params.get_limit(), MAX_PAGE_SIZE);
509 assert_eq!(params.get_offset(), MAX_PAGE_SIZE);
510
511 let huge = PaginationParams {
512 page: Some(u32::MAX),
513 limit: Some(u32::MAX),
514 offset: None,
515 };
516 assert_eq!(
517 huge.get_offset(),
518 u32::MAX,
519 "saturates instead of overflowing"
520 );
521
522 let zero = PaginationParams {
523 page: Some(0),
524 limit: Some(0),
525 offset: None,
526 };
527 assert_eq!(zero.get_limit(), 1, "a zero limit is clamped to one");
528 assert_eq!(zero.get_offset(), 0);
529 let meta = PaginationMeta::new(&zero, 3);
530 assert_eq!(meta.total_pages, 3);
531
532 let explicit = PaginationParams {
533 page: Some(7),
534 limit: Some(10),
535 offset: Some(5),
536 };
537 assert_eq!(explicit.get_offset(), 5, "an explicit offset wins");
538 }
539
540 #[test]
541 fn test_pagination_meta() {
542 let params = PaginationParams {
543 page: Some(2),
544 limit: Some(10),
545 offset: None,
546 };
547 let meta = PaginationMeta::new(¶ms, 45);
548
549 assert_eq!(meta.page, 2);
550 assert_eq!(meta.limit, 10);
551 assert_eq!(meta.total, 45);
552 assert_eq!(meta.total_pages, 5);
553 assert!(meta.has_next);
554 assert!(meta.has_prev);
555 }
556
557 #[test]
558 fn test_pagination_meta_describes_an_explicit_offset() {
559 let last_page = PaginationParams {
560 page: None,
561 limit: Some(50),
562 offset: Some(100),
563 };
564 let meta = PaginationMeta::new(&last_page, 120);
565 assert_eq!(meta.page, 3);
566 assert_eq!(meta.offset, 100);
567 assert!(!meta.has_next);
568 assert!(meta.has_prev);
569
570 let first_items = PaginationParams {
572 page: Some(3),
573 limit: Some(50),
574 offset: Some(0),
575 };
576 let meta = PaginationMeta::new(&first_items, 120);
577 assert_eq!(meta.page, 1);
578 assert!(meta.has_next);
579 assert!(!meta.has_prev);
580 }
581
582 #[test]
583 fn test_sort_params_defaults() {
584 let params = SortParams {
585 sort_by: None,
586 sort_order: None,
587 };
588 let (field, direction) = params.get_order_by();
589 assert_eq!(field, "created_at");
590 assert_eq!(direction, "DESC");
591 }
592
593 #[test]
596 fn sort_order_is_case_insensitive() {
597 let order = |order: Option<&str>| SortParams {
598 sort_by: Some("created_at".to_string()),
599 sort_order: order.map(str::to_string),
600 };
601 for ascending in ["asc", "ASC", "Asc", " asc "] {
602 assert!(order(Some(ascending)).is_ascending(), "{ascending}");
603 assert_eq!(order(Some(ascending)).get_order_by().1, "ASC");
604 }
605 for descending in [None, Some("desc"), Some("DESC"), Some("ascending")] {
606 assert!(!order(descending).is_ascending(), "{descending:?}");
607 assert_eq!(order(descending).get_order_by().1, "DESC");
608 }
609 }
610
611 #[test]
612 fn test_sort_params_custom() {
613 let params = SortParams {
614 sort_by: Some("name".to_string()),
615 sort_order: Some("asc".to_string()),
616 };
617 let (field, direction) = params.get_order_by();
618 assert_eq!(field, "name");
619 assert_eq!(direction, "ASC");
620 }
621
622 #[tokio::test]
623 async fn test_internal_error_is_500_with_json_error_body() {
624 let (status, body) = body_json(internal_error("Failed to list jobs", &"db down")).await;
625 assert_eq!(status, 500);
626 assert_eq!(body["success"], false);
627 assert_eq!(body["error"], "Failed to list jobs: db down");
628 assert!(body["data"].is_null());
629 }
630
631 #[tokio::test]
632 async fn test_error_reply_uses_given_status() {
633 let (status, body) =
634 body_json(error_reply(warp::http::StatusCode::BAD_REQUEST, "nope")).await;
635 assert_eq!(status, 400);
636 assert_eq!(body["error"], "nope");
637 }
638
639 #[tokio::test]
640 async fn test_json_reply_is_200() {
641 let (status, body) = body_json(json_reply(&ApiResponse::success(5))).await;
642 assert_eq!(status, 200);
643 assert_eq!(body["data"], 5);
644 }
645}
646
647#[cfg(test)]
651mod undecodable_row_tests {
652 use super::history::JobHistory;
653 use hammerwork::archive::{ArchivalConfig, ArchivalPolicy, ArchivalReason};
654 use hammerwork::{Job, JobId};
655 use serde_json::{Value, json};
656 use std::future::Future;
657 use std::sync::Arc;
658 use warp::Filter;
659
660 fn listed(body: &Value) -> Vec<String> {
662 let mut ids: Vec<String> = body["data"]["items"]
663 .as_array()
664 .unwrap_or_else(|| panic!("no items: {body}"))
665 .iter()
666 .map(|item| item["id"].as_str().unwrap().to_string())
667 .collect();
668 ids.sort();
669 ids
670 }
671
672 async fn listings_skip_undecodable_rows<Q, S, F>(
675 queue: Arc<Q>,
676 sql: S,
677 corrupt_archive_id: bool,
678 ) where
679 Q: JobHistory + 'static,
680 S: Fn(String) -> F,
681 F: Future<Output = ()>,
682 {
683 let routes = super::jobs::routes(queue.clone())
684 .or(super::queues::routes(queue.clone()))
685 .or(super::archive::archive_routes(queue.clone()));
686 let get = |path: String| {
687 let routes = routes.clone();
688 async move {
689 let response = warp::test::request().path(&path).reply(&routes).await;
690 let body: Value = serde_json::from_slice(response.body()).unwrap();
691 (response.status().as_u16(), body)
692 }
693 };
694
695 let tag = uuid::Uuid::new_v4().simple().to_string();
696 let name = format!("undecodable_{tag}");
697 let archive_name = format!("undecodable_archive_{tag}");
698 let enqueue = |queue_name: &str| queue.enqueue(Job::new(queue_name.to_string(), json!({})));
699 let break_timeout = |id: JobId| {
700 sql(format!(
701 "UPDATE hammerwork_jobs SET timeout_seconds = -1 WHERE id = '{id}'"
702 ))
703 };
704
705 let ready = enqueue(&name).await.unwrap();
706 let bad_ready = enqueue(&name).await.unwrap();
707 break_timeout(bad_ready).await;
708 let dead = enqueue(&name).await.unwrap();
709 let bad_dead = enqueue(&name).await.unwrap();
710 for id in [dead, bad_dead] {
711 queue.mark_job_dead(id, "gone").await.unwrap();
712 }
713 break_timeout(bad_dead).await;
714 let mut healthy = vec![ready.to_string(), dead.to_string()];
715 healthy.sort();
716
717 for path in [
719 format!("/jobs?queue={name}&limit=100"),
720 format!("/queues/{name}/jobs?limit=100"),
721 ] {
722 let (status, body) = get(path.clone()).await;
723 assert_eq!(status, 200, "{path}: {body}");
724 assert_eq!(listed(&body), healthy, "{path}");
725 }
726 let response = warp::test::request()
727 .method("POST")
728 .path("/jobs/search?limit=100")
729 .json(&json!({"query": "", "queues": [name]}))
730 .reply(&routes)
731 .await;
732 let body: Value = serde_json::from_slice(response.body()).unwrap();
733 assert_eq!(response.status(), 200, "{body}");
734 assert_eq!(listed(&body), healthy, "search");
735
736 let (status, body) = get(format!("/jobs/{bad_ready}")).await;
738 assert_eq!(status, 500, "{body}");
739 assert!(
740 body["error"].as_str().unwrap().contains("timeout_seconds"),
741 "{body}"
742 );
743
744 let archived = enqueue(&archive_name).await.unwrap();
746 let bad_archived = enqueue(&archive_name).await.unwrap();
747 for id in [archived, bad_archived] {
748 queue.mark_job_dead(id, "gone").await.unwrap();
749 }
750 let policy = ArchivalPolicy::new()
751 .archive_dead_after(chrono::Duration::seconds(0))
752 .enabled(true);
753 queue
754 .archive_jobs(
755 Some(&archive_name),
756 &policy,
757 &ArchivalConfig::new(),
758 ArchivalReason::Manual,
759 None,
760 )
761 .await
762 .unwrap();
763 if corrupt_archive_id {
764 sql(format!(
765 "UPDATE hammerwork_jobs_archive SET id = 'not-a-uuid' WHERE id = '{bad_archived}'"
766 ))
767 .await;
768 }
769 let (status, body) = get(format!("/archive/jobs?queue={archive_name}")).await;
770 assert_eq!(status, 200, "{body}");
771 let ids = listed(&body);
772 assert!(ids.contains(&archived.to_string()), "{body}");
773 if corrupt_archive_id {
774 assert_eq!(ids, [archived.to_string()], "{body}");
775 }
776
777 for (table, queue_name) in [
778 ("hammerwork_jobs", &name),
779 ("hammerwork_jobs_archive", &archive_name),
780 ] {
781 sql(format!(
782 "DELETE FROM {table} WHERE queue_name = '{queue_name}'"
783 ))
784 .await;
785 }
786 }
787
788 #[tokio::test]
789 #[ignore = "requires DATABASE_URL (PostgreSQL)"]
790 async fn postgres_listings_skip_undecodable_rows() {
791 let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
792 let pool = sqlx::PgPool::connect(&url).await.unwrap();
793 let queue = Arc::new(hammerwork::JobQueue::new(pool.clone()));
794 let sql = |statement: String| {
795 let pool = pool.clone();
796 async move {
797 sqlx::query(&statement).execute(&pool).await.unwrap();
798 }
799 };
800 listings_skip_undecodable_rows(queue, sql, false).await;
801 }
802
803 #[tokio::test]
804 #[ignore = "requires MYSQL_DATABASE_URL"]
805 async fn mysql_listings_skip_undecodable_rows() {
806 let url = std::env::var("MYSQL_DATABASE_URL").expect("MYSQL_DATABASE_URL");
807 let pool = sqlx::MySqlPool::connect(&url).await.unwrap();
808 let queue = Arc::new(hammerwork::JobQueue::new(pool.clone()));
809 let sql = |statement: String| {
810 let pool = pool.clone();
811 async move {
812 sqlx::query(&statement).execute(&pool).await.unwrap();
813 }
814 };
815 listings_skip_undecodable_rows(queue, sql, true).await;
816 }
817}