Skip to main content

hammerwork_web/api/
archive.rs

1//! Archive management API endpoints.
2//!
3//! This module provides REST API endpoints for managing job archiving operations,
4//! including archiving jobs, restoring archived jobs, listing archived jobs,
5//! and configuring archival policies.
6//!
7//! # Endpoints
8//!
9//! - `POST /api/archive/jobs` - Archive jobs based on policy
10//! - `GET /api/archive/jobs` - List archived jobs with pagination and filtering
11//! - `POST /api/archive/jobs/{id}/restore` - Restore an archived job
12//! - `DELETE /api/archive/jobs` - Purge old archived jobs
13//! - `GET /api/archive/stats` - Get archival statistics
14//! - `GET /api/archive/policies` - List archival policies
15//! - `POST /api/archive/policies` - Create or update archival policy
16//! - `DELETE /api/archive/policies/{id}` - Delete archival policy
17//!
18//! # Examples
19//!
20//! ## Archive Jobs
21//!
22//! ```rust
23//! use hammerwork_web::api::archive::{ArchiveRequest, ArchiveResponse};
24//! use hammerwork::archive::{ArchivalReason, ArchivalPolicy};
25//! use chrono::Duration;
26//!
27//! let request = ArchiveRequest {
28//!     queue_name: Some("completed_jobs".to_string()),
29//!     reason: ArchivalReason::Automatic,
30//!     archived_by: Some("scheduler".to_string()),
31//!     dry_run: false,
32//!     policy: Some(ArchivalPolicy::new()
33//!         .archive_completed_after(Duration::days(7))),
34//!     config: None,
35//! };
36//!
37//! // This would be sent to POST /api/archive/jobs
38//! assert_eq!(request.queue_name, Some("completed_jobs".to_string()));
39//! assert!(!request.dry_run);
40//! ```
41//!
42//! ## List Archived Jobs
43//!
44//! ```rust
45//! use hammerwork_web::api::archive::ArchivedJobInfo;
46//! use hammerwork::archive::{ArchivalReason, ArchivedJob};
47//! use hammerwork::{JobId, JobStatus};
48//! use chrono::Utc;
49//! use uuid::Uuid;
50//!
51//! let archived_job = ArchivedJobInfo {
52//!     id: Uuid::new_v4(),
53//!     queue_name: "email_queue".to_string(),
54//!     status: JobStatus::Completed,
55//!     created_at: Utc::now(),
56//!     archived_at: Utc::now(),
57//!     archival_reason: ArchivalReason::Automatic,
58//!     original_payload_size: Some(1024),
59//!     payload_compressed: true,
60//!     archived_by: Some("system".to_string()),
61//! };
62//!
63//! assert_eq!(archived_job.queue_name, "email_queue");
64//! assert!(archived_job.payload_compressed);
65//! ```
66
67use super::history::JobHistory;
68use super::{
69    ApiResponse, FilterParams, PaginatedResponse, PaginationMeta, PaginationParams, error_reply,
70    internal_error, json_reply,
71};
72use hammerwork::{
73    JobId, JobStatus,
74    archive::{ArchivalConfig, ArchivalPolicy, ArchivalReason, ArchivalStats, ArchivedJob},
75};
76use serde::{Deserialize, Serialize};
77use std::sync::Arc;
78use warp::http::StatusCode;
79use warp::{Filter, Reply};
80
81/// Request to archive jobs
82#[derive(Debug, Deserialize)]
83pub struct ArchiveRequest {
84    /// Optional queue name to limit archival to specific queue
85    pub queue_name: Option<String>,
86    /// Reason for archival
87    pub reason: ArchivalReason,
88    /// Who initiated the archival
89    pub archived_by: Option<String>,
90    /// Whether this is a dry run (don't actually archive)
91    pub dry_run: bool,
92    /// Archival policy to use (optional, uses default if not provided)
93    pub policy: Option<ArchivalPolicy>,
94    /// Archival configuration (optional, uses default if not provided)
95    pub config: Option<ArchivalConfig>,
96}
97
98impl Default for ArchiveRequest {
99    fn default() -> Self {
100        Self {
101            queue_name: None,
102            reason: ArchivalReason::Manual,
103            archived_by: None,
104            dry_run: true,
105            policy: None,
106            config: None,
107        }
108    }
109}
110
111/// Response from archiving jobs
112#[derive(Debug, Serialize)]
113pub struct ArchiveResponse {
114    /// Statistics from the archival operation
115    pub stats: ArchivalStats,
116    /// Whether this was a dry run
117    pub dry_run: bool,
118    /// Policy used for archival
119    pub policy_used: ArchivalPolicy,
120    /// Configuration used for archival
121    pub config_used: ArchivalConfig,
122}
123
124/// Request to restore an archived job
125#[derive(Debug, Deserialize)]
126pub struct RestoreRequest {
127    /// Optional reason for restoration
128    pub reason: Option<String>,
129    /// Who initiated the restoration
130    pub restored_by: Option<String>,
131}
132
133/// Response from restoring a job
134#[derive(Debug, Serialize)]
135pub struct RestoreResponse {
136    /// The restored job
137    pub job: hammerwork::Job,
138    /// When the job was restored
139    pub restored_at: chrono::DateTime<chrono::Utc>,
140    /// Who restored the job
141    pub restored_by: Option<String>,
142}
143
144/// Request to purge archived jobs
145#[derive(Debug, Deserialize)]
146pub struct PurgeRequest {
147    /// Delete archived jobs older than this date
148    pub older_than: chrono::DateTime<chrono::Utc>,
149    /// Whether this is a dry run
150    pub dry_run: bool,
151    /// Who initiated the purge
152    pub purged_by: Option<String>,
153}
154
155/// Response from purging archived jobs
156#[derive(Debug, Serialize)]
157pub struct PurgeResponse {
158    /// Number of jobs that would be (or were) purged
159    pub jobs_purged: u64,
160    /// Whether this was a dry run
161    pub dry_run: bool,
162    /// When the purge was executed
163    pub executed_at: chrono::DateTime<chrono::Utc>,
164}
165
166/// Archived job information for API responses
167#[derive(Debug, Serialize)]
168pub struct ArchivedJobInfo {
169    /// Job ID
170    pub id: JobId,
171    /// Queue name
172    pub queue_name: String,
173    /// Original job status
174    pub status: JobStatus,
175    /// When the job was created
176    pub created_at: chrono::DateTime<chrono::Utc>,
177    /// When the job was archived
178    pub archived_at: chrono::DateTime<chrono::Utc>,
179    /// Reason for archival
180    pub archival_reason: ArchivalReason,
181    /// Original payload size in bytes
182    pub original_payload_size: Option<usize>,
183    /// Whether the payload was compressed
184    pub payload_compressed: bool,
185    /// Who archived the job
186    pub archived_by: Option<String>,
187}
188
189impl From<ArchivedJob> for ArchivedJobInfo {
190    fn from(archived_job: ArchivedJob) -> Self {
191        Self {
192            id: archived_job.id,
193            queue_name: archived_job.queue_name,
194            status: archived_job.status,
195            created_at: archived_job.created_at,
196            archived_at: archived_job.archived_at,
197            archival_reason: archived_job.archival_reason,
198            original_payload_size: archived_job.original_payload_size,
199            payload_compressed: archived_job.payload_compressed,
200            archived_by: archived_job.archived_by,
201        }
202    }
203}
204
205/// Archive statistics response
206#[derive(Debug, Serialize)]
207pub struct StatsResponse {
208    /// Overall archival statistics
209    pub stats: ArchivalStats,
210    /// Statistics by queue
211    pub by_queue: std::collections::HashMap<String, ArchivalStats>,
212    /// Recent archival operations
213    pub recent_operations: Vec<RecentOperation>,
214}
215
216/// Information about recent archival operations
217#[derive(Debug, Serialize)]
218pub struct RecentOperation {
219    /// Type of operation (archive, restore, purge)
220    pub operation_type: String,
221    /// Queue affected (if applicable)
222    pub queue_name: Option<String>,
223    /// Number of jobs affected
224    pub jobs_affected: u64,
225    /// When the operation occurred
226    pub executed_at: chrono::DateTime<chrono::Utc>,
227    /// Who executed the operation
228    pub executed_by: Option<String>,
229    /// Operation reason
230    pub reason: Option<String>,
231}
232
233/// Archive filter parameters
234#[derive(Debug, Deserialize, Default)]
235pub struct ArchiveFilterParams {
236    /// Filter by queue name
237    pub queue: Option<String>,
238    /// Filter by archival reason
239    pub reason: Option<String>,
240    /// Filter by archived after date
241    pub archived_after: Option<chrono::DateTime<chrono::Utc>>,
242    /// Filter by archived before date
243    pub archived_before: Option<chrono::DateTime<chrono::Utc>>,
244    /// Filter by who archived
245    pub archived_by: Option<String>,
246    /// Filter by compression status
247    pub compressed: Option<bool>,
248    /// Filter by original job status
249    pub original_status: Option<String>,
250}
251
252/// Create archive API routes
253pub fn archive_routes<Q>(
254    queue: Arc<Q>,
255) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone
256where
257    Q: JobHistory + 'static,
258{
259    let archive_jobs = warp::path!("archive" / "jobs")
260        .and(warp::post())
261        .and(crate::security::json_body())
262        .and(with_queue(queue.clone()))
263        .and_then(handle_archive_jobs);
264
265    let list_archived = warp::path!("archive" / "jobs")
266        .and(warp::get())
267        .and(super::with_pagination())
268        .and(with_archive_filters())
269        .and(with_queue(queue.clone()))
270        .and_then(handle_list_archived_jobs);
271
272    let restore_job = warp::path!("archive" / "jobs" / String / "restore")
273        .and(warp::post())
274        .and(crate::security::json_body())
275        .and(with_queue(queue.clone()))
276        .and_then(handle_restore_job);
277
278    let purge_jobs = warp::path!("archive" / "purge")
279        .and(warp::delete())
280        .and(crate::security::json_body())
281        .and(with_queue(queue.clone()))
282        .and_then(handle_purge_jobs);
283
284    let archive_stats = warp::path!("archive" / "stats")
285        .and(warp::get())
286        .and(warp::query::<FilterParams>())
287        .and(with_queue(queue.clone()))
288        .and_then(handle_archive_stats);
289
290    archive_jobs
291        .or(list_archived)
292        .or(restore_job)
293        .or(purge_jobs)
294        .or(archive_stats)
295}
296
297/// Helper to inject queue into handlers
298fn with_queue<Q>(
299    queue: Arc<Q>,
300) -> impl Filter<Extract = (Arc<Q>,), Error = std::convert::Infallible> + Clone
301where
302    Q: JobHistory + 'static,
303{
304    warp::any().map(move || queue.clone())
305}
306
307/// Extract archive filter parameters from query string
308fn with_archive_filters()
309-> impl Filter<Extract = (ArchiveFilterParams,), Error = warp::Rejection> + Clone {
310    warp::query::<ArchiveFilterParams>()
311}
312
313/// Handle archive jobs request
314async fn handle_archive_jobs<Q>(
315    request: ArchiveRequest,
316    queue: Arc<Q>,
317) -> Result<impl Reply, warp::Rejection>
318where
319    Q: JobHistory + 'static,
320{
321    let policy = request.policy.unwrap_or_default();
322    let config = request.config.unwrap_or_default();
323
324    if request.dry_run {
325        // Count the jobs a real run would archive now; nothing is written, so the other
326        // statistics (sizes, compression, duration) stay at their defaults.
327        let would_archive = try_api!(
328            queue
329                .count_archivable(request.queue_name.as_deref(), &policy)
330                .await,
331            "Failed to count archivable jobs"
332        );
333        let response = ArchiveResponse {
334            stats: ArchivalStats {
335                jobs_archived: would_archive,
336                ..ArchivalStats::default()
337            },
338            dry_run: true,
339            policy_used: policy,
340            config_used: config,
341        };
342        return Ok(json_reply(&ApiResponse::success(response)));
343    }
344
345    match queue
346        .archive_jobs(
347            request.queue_name.as_deref(),
348            &policy,
349            &config,
350            request.reason,
351            request.archived_by.as_deref(),
352        )
353        .await
354    {
355        Ok(stats) => {
356            let response = ArchiveResponse {
357                stats,
358                dry_run: false,
359                policy_used: policy,
360                config_used: config,
361            };
362            Ok(json_reply(&ApiResponse::success(response)))
363        }
364        Err(e) => Ok(internal_error("Failed to archive jobs", &e)),
365    }
366}
367
368/// Handle list archived jobs request. Filtering, counting and paging happen in the database,
369/// so a request reads one page, never the whole archive.
370async fn handle_list_archived_jobs<Q>(
371    pagination: PaginationParams,
372    filters: ArchiveFilterParams,
373    queue: Arc<Q>,
374) -> Result<impl Reply, warp::Rejection>
375where
376    Q: JobHistory + 'static,
377{
378    let limit = pagination.get_limit();
379    let offset = pagination.get_offset();
380
381    let (page, total) = try_api!(
382        queue.archived_jobs(&filters, limit, offset).await,
383        "Failed to list archived jobs"
384    );
385
386    let response = PaginatedResponse {
387        items: page.into_iter().map(ArchivedJobInfo::from).collect(),
388        pagination: PaginationMeta::new(&pagination, total),
389    };
390    Ok(json_reply(&ApiResponse::success(response)))
391}
392
393/// Handle restore job request
394async fn handle_restore_job<Q>(
395    job_id_str: String,
396    request: RestoreRequest,
397    queue: Arc<Q>,
398) -> Result<impl Reply, warp::Rejection>
399where
400    Q: JobHistory + 'static,
401{
402    let job_id = match uuid::Uuid::parse_str(&job_id_str) {
403        Ok(id) => id,
404        Err(_) => {
405            return Ok(error_reply(
406                StatusCode::BAD_REQUEST,
407                "Invalid job ID format",
408            ));
409        }
410    };
411
412    match queue.restore_archived_job(job_id).await {
413        Ok(job) => {
414            let response = RestoreResponse {
415                job,
416                restored_at: chrono::Utc::now(),
417                restored_by: request.restored_by,
418            };
419            Ok(json_reply(&ApiResponse::success(response)))
420        }
421        Err(e) => Ok(super::queue_error_reply("Failed to restore job", &e)),
422    }
423}
424
425/// Handle purge jobs request
426async fn handle_purge_jobs<Q>(
427    request: PurgeRequest,
428    queue: Arc<Q>,
429) -> Result<impl Reply, warp::Rejection>
430where
431    Q: JobHistory + 'static,
432{
433    if request.dry_run {
434        // Count the archived jobs the real purge would delete: those archived at or before
435        // the cutoff. Only the count is read.
436        let cutoff = ArchiveFilterParams {
437            archived_before: Some(request.older_than),
438            ..ArchiveFilterParams::default()
439        };
440        let count = try_api!(
441            queue.archived_jobs(&cutoff, 0, 0).await,
442            "Failed to count archived jobs"
443        )
444        .1;
445
446        let response = PurgeResponse {
447            jobs_purged: count,
448            dry_run: true,
449            executed_at: chrono::Utc::now(),
450        };
451        return Ok(json_reply(&ApiResponse::success(response)));
452    }
453
454    match queue.purge_archived_jobs(request.older_than).await {
455        Ok(jobs_purged) => {
456            let response = PurgeResponse {
457                jobs_purged,
458                dry_run: false,
459                executed_at: chrono::Utc::now(),
460            };
461            Ok(json_reply(&ApiResponse::success(response)))
462        }
463        Err(e) => Ok(internal_error("Failed to purge archived jobs", &e)),
464    }
465}
466
467/// Handle archive stats request
468async fn handle_archive_stats<Q>(
469    filters: FilterParams,
470    queue: Arc<Q>,
471) -> Result<impl Reply, warp::Rejection>
472where
473    Q: JobHistory + 'static,
474{
475    match queue.get_archival_stats(filters.queue.as_deref()).await {
476        Ok(stats) => {
477            // Collect per-queue stats if no specific queue is filtered
478            let mut by_queue = std::collections::HashMap::new();
479
480            if filters.queue.is_none() {
481                // Get queue list and collect stats for each
482                let queue_stats = try_api!(
483                    queue.get_all_queue_stats().await,
484                    "Failed to get queue stats"
485                );
486                for queue_stat in queue_stats {
487                    match queue.get_archival_stats(Some(&queue_stat.queue_name)).await {
488                        Ok(queue_archival_stats) => {
489                            by_queue.insert(queue_stat.queue_name, queue_archival_stats);
490                        }
491                        Err(e) => {
492                            return Ok(internal_error(
493                                &format!(
494                                    "Failed to get archive stats for queue '{}'",
495                                    queue_stat.queue_name
496                                ),
497                                &e,
498                            ));
499                        }
500                    }
501                }
502            }
503
504            // Archive/purge operations are not recorded anywhere, so there is no history to
505            // report; an empty list is the truth.
506            let recent_operations: Vec<RecentOperation> = Vec::new();
507
508            let response = StatsResponse {
509                stats,
510                by_queue,
511                recent_operations,
512            };
513            Ok(json_reply(&ApiResponse::success(response)))
514        }
515        Err(e) => Ok(internal_error("Failed to get archive stats", &e)),
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use crate::api::test_support::{body_json, unreachable_queue};
523
524    #[tokio::test]
525    async fn test_dry_run_purge_returns_500_instead_of_zero_when_database_is_down() {
526        let request: PurgeRequest = serde_json::from_value(serde_json::json!({
527            "older_than": "2024-01-01T00:00:00Z",
528            "dry_run": true
529        }))
530        .unwrap();
531        let response = handle_purge_jobs(request, unreachable_queue())
532            .await
533            .unwrap()
534            .into_response();
535        let (status, body) = body_json(response).await;
536        assert_eq!(status, 500);
537        assert_eq!(body["success"], false);
538        assert!(body["data"].is_null());
539    }
540
541    #[tokio::test]
542    async fn test_archive_stats_returns_500_when_database_is_down() {
543        let filters: FilterParams = serde_json::from_value(serde_json::json!({})).unwrap();
544        let response = handle_archive_stats(filters, unreachable_queue())
545            .await
546            .unwrap()
547            .into_response();
548        let (status, _) = body_json(response).await;
549        assert_eq!(status, 500);
550    }
551    use chrono::Duration;
552
553    #[test]
554    fn test_archive_request_default() {
555        let request = ArchiveRequest::default();
556        assert!(request.dry_run);
557        assert_eq!(request.reason, ArchivalReason::Manual);
558        assert!(request.queue_name.is_none());
559    }
560
561    #[test]
562    fn test_archived_job_info_conversion() {
563        let archived_job = ArchivedJob {
564            id: uuid::Uuid::new_v4(),
565            queue_name: "test_queue".to_string(),
566            status: JobStatus::Completed,
567            created_at: chrono::Utc::now(),
568            archived_at: chrono::Utc::now(),
569            archival_reason: ArchivalReason::Automatic,
570            original_payload_size: Some(1024),
571            payload_compressed: true,
572            archived_by: Some("system".to_string()),
573        };
574
575        let info: ArchivedJobInfo = archived_job.into();
576        assert_eq!(info.queue_name, "test_queue");
577        assert!(info.payload_compressed);
578        assert_eq!(info.archival_reason, ArchivalReason::Automatic);
579    }
580
581    #[test]
582    fn test_purge_request_validation() {
583        let request = PurgeRequest {
584            older_than: chrono::Utc::now() - Duration::days(365),
585            dry_run: true,
586            purged_by: Some("admin".to_string()),
587        };
588
589        assert!(request.dry_run);
590        assert_eq!(request.purged_by, Some("admin".to_string()));
591    }
592}