Skip to main content

cargo_hammerwork/commands/
webhook.rs

1use anyhow::Result;
2use clap::Subcommand;
3use serde_json::json;
4use std::collections::HashMap;
5use tracing::{error, info};
6use uuid::Uuid;
7
8use crate::config::Config;
9use crate::utils::display::create_table;
10
11#[derive(Clone, Subcommand)]
12pub enum WebhookCommand {
13    #[command(about = "List all configured webhooks")]
14    List {
15        #[arg(short, long, help = "Show detailed webhook information")]
16        detailed: bool,
17    },
18    #[command(about = "Add a new webhook")]
19    Add {
20        #[arg(short, long, help = "Webhook name")]
21        name: String,
22        #[arg(short, long, help = "Webhook URL")]
23        url: String,
24        #[arg(
25            short = 'm',
26            long,
27            help = "HTTP method (POST, PUT, PATCH)",
28            default_value = "POST"
29        )]
30        method: String,
31        #[arg(long, help = "Event types to filter (comma-separated)")]
32        events: Option<String>,
33        #[arg(long, help = "Queue names to filter (comma-separated)")]
34        queues: Option<String>,
35        #[arg(long, help = "Job priorities to filter (comma-separated)")]
36        priorities: Option<String>,
37        #[arg(long, help = "Custom headers in key=value format (comma-separated)")]
38        headers: Option<String>,
39        #[arg(long, help = "Authentication token for Bearer auth")]
40        auth_token: Option<String>,
41        #[arg(long, help = "Basic auth in username:password format")]
42        basic_auth: Option<String>,
43        #[arg(long, help = "API key header name")]
44        api_key_header: Option<String>,
45        #[arg(long, help = "API key value")]
46        api_key: Option<String>,
47        #[arg(long, help = "Secret for HMAC signatures")]
48        secret: Option<String>,
49        #[arg(long, help = "Request timeout in seconds", default_value = "30")]
50        timeout: u64,
51        #[arg(long, help = "Maximum retry attempts", default_value = "3")]
52        max_retries: u32,
53        #[arg(long, help = "Include job payload in events")]
54        include_payload: bool,
55    },
56    #[command(about = "Remove a webhook")]
57    Remove {
58        #[arg(short, long, help = "Webhook ID or name")]
59        webhook: String,
60        #[arg(long, help = "Confirm the operation")]
61        confirm: bool,
62    },
63    #[command(about = "Test a webhook")]
64    Test {
65        #[arg(short, long, help = "Webhook ID or name")]
66        webhook: String,
67        #[arg(long, help = "Test event type", default_value = "completed")]
68        event_type: String,
69        #[arg(long, help = "Test job ID")]
70        job_id: Option<String>,
71        #[arg(long, help = "Test queue name", default_value = "test")]
72        queue: String,
73    },
74    #[command(about = "Show webhook statistics")]
75    Stats {
76        #[arg(short, long, help = "Webhook ID or name")]
77        webhook: Option<String>,
78        #[arg(long, help = "Time window in hours", default_value = "24")]
79        hours: u64,
80    },
81    #[command(about = "Enable or disable a webhook")]
82    Toggle {
83        #[arg(short, long, help = "Webhook ID or name")]
84        webhook: String,
85        #[arg(long, help = "Enable the webhook")]
86        enable: bool,
87    },
88    #[command(about = "Update webhook configuration")]
89    Update {
90        #[arg(short, long, help = "Webhook ID or name")]
91        webhook: String,
92        #[arg(short, long, help = "New webhook name")]
93        name: Option<String>,
94        #[arg(short, long, help = "New webhook URL")]
95        url: Option<String>,
96        #[arg(short = 'm', long, help = "New HTTP method")]
97        method: Option<String>,
98        #[arg(long, help = "New event types filter")]
99        events: Option<String>,
100        #[arg(long, help = "New queue names filter")]
101        queues: Option<String>,
102        #[arg(long, help = "New priorities filter")]
103        priorities: Option<String>,
104        #[arg(long, help = "New custom headers")]
105        headers: Option<String>,
106        #[arg(long, help = "New timeout in seconds")]
107        timeout: Option<u64>,
108        #[arg(long, help = "New max retry attempts")]
109        max_retries: Option<u32>,
110    },
111}
112
113pub async fn handle_webhook_command(command: WebhookCommand, config: &Config) -> Result<()> {
114    match command {
115        WebhookCommand::List { detailed } => list_webhooks(config, detailed).await,
116        WebhookCommand::Add {
117            name,
118            url,
119            method,
120            events,
121            queues,
122            priorities,
123            headers,
124            auth_token,
125            basic_auth,
126            api_key_header,
127            api_key,
128            secret,
129            timeout,
130            max_retries,
131            include_payload,
132        } => {
133            add_webhook(
134                config,
135                name,
136                url,
137                method,
138                events,
139                queues,
140                priorities,
141                headers,
142                auth_token,
143                basic_auth,
144                api_key_header,
145                api_key,
146                secret,
147                timeout,
148                max_retries,
149                include_payload,
150            )
151            .await
152        }
153        WebhookCommand::Remove { webhook, confirm } => {
154            remove_webhook(config, webhook, confirm).await
155        }
156        WebhookCommand::Test {
157            webhook,
158            event_type,
159            job_id,
160            queue,
161        } => test_webhook(config, webhook, event_type, job_id, queue).await,
162        WebhookCommand::Stats { webhook, hours } => {
163            show_webhook_stats(config, webhook, hours).await
164        }
165        WebhookCommand::Toggle { webhook, enable } => toggle_webhook(config, webhook, enable).await,
166        WebhookCommand::Update {
167            webhook,
168            name,
169            url,
170            method,
171            events,
172            queues,
173            priorities,
174            headers,
175            timeout,
176            max_retries,
177        } => {
178            update_webhook(
179                config,
180                webhook,
181                name,
182                url,
183                method,
184                events,
185                queues,
186                priorities,
187                headers,
188                timeout,
189                max_retries,
190            )
191            .await
192        }
193    }
194}
195
196async fn list_webhooks(config: &Config, detailed: bool) -> Result<()> {
197    let webhooks = load_webhooks_config(config)?;
198
199    if webhooks.is_empty() {
200        info!("No webhooks configured.");
201        return Ok(());
202    }
203
204    if detailed {
205        for webhook in webhooks {
206            println!("\n๐Ÿ“Ž Webhook: {}", webhook.name);
207            println!("  ID: {}", webhook.id);
208            println!("  URL: {}", webhook.url);
209            println!("  Method: {}", webhook.method);
210            println!("  Enabled: {}", webhook.enabled);
211            println!("  Timeout: {}s", webhook.timeout);
212            println!("  Max Retries: {}", webhook.max_retries);
213
214            if !webhook.headers.is_empty() {
215                println!("  Headers:");
216                for (key, value) in &webhook.headers {
217                    println!("    {}: {}", key, value);
218                }
219            }
220
221            if webhook.auth.is_some() {
222                println!("  Authentication: Configured");
223            }
224
225            if webhook.secret.is_some() {
226                println!("  HMAC Secret: Configured");
227            }
228        }
229    } else {
230        let mut table = create_table();
231        table.set_header(vec!["Name", "URL", "Method", "Enabled", "Events"]);
232
233        for webhook in webhooks {
234            let events_filter = if webhook.filter.event_types.is_empty() {
235                "All".to_string()
236            } else {
237                webhook
238                    .filter
239                    .event_types
240                    .iter()
241                    .map(|e| format!("{:?}", e))
242                    .collect::<Vec<_>>()
243                    .join(", ")
244            };
245
246            table.add_row(vec![
247                webhook.name,
248                webhook.url,
249                webhook.method.to_string(),
250                if webhook.enabled { "โœ“" } else { "โœ—" }.to_string(),
251                events_filter,
252            ]);
253        }
254
255        println!("{}", table);
256    }
257
258    Ok(())
259}
260
261async fn add_webhook(
262    config: &Config,
263    name: String,
264    url: String,
265    method: String,
266    events: Option<String>,
267    queues: Option<String>,
268    priorities: Option<String>,
269    headers: Option<String>,
270    auth_token: Option<String>,
271    basic_auth: Option<String>,
272    api_key_header: Option<String>,
273    api_key: Option<String>,
274    secret: Option<String>,
275    timeout: u64,
276    max_retries: u32,
277    include_payload: bool,
278) -> Result<()> {
279    // Create webhook configuration
280    let webhook_config = create_webhook_config(
281        name.clone(),
282        url,
283        method,
284        events,
285        queues,
286        priorities,
287        headers,
288        auth_token,
289        basic_auth,
290        api_key_header,
291        api_key,
292        secret,
293        timeout,
294        max_retries,
295        include_payload,
296    )?;
297
298    // Save to configuration
299    save_webhook_config(config, webhook_config)?;
300
301    info!("โœ… Webhook '{}' added successfully", name);
302    Ok(())
303}
304
305async fn remove_webhook(config: &Config, webhook_id: String, confirm: bool) -> Result<()> {
306    if !confirm {
307        error!("โŒ Use --confirm to confirm webhook removal");
308        return Ok(());
309    }
310
311    let mut webhooks = load_webhooks_config(config)?;
312    let initial_len = webhooks.len();
313
314    webhooks.retain(|w| w.name != webhook_id && w.id.to_string() != webhook_id);
315
316    if webhooks.len() == initial_len {
317        error!("โŒ Webhook '{}' not found", webhook_id);
318        return Ok(());
319    }
320
321    save_webhooks_config(config, webhooks)?;
322    info!("โœ… Webhook '{}' removed successfully", webhook_id);
323    Ok(())
324}
325
326async fn test_webhook(
327    _config: &Config,
328    webhook_id: String,
329    event_type: String,
330    job_id: Option<String>,
331    queue: String,
332) -> Result<()> {
333    // Create a test event
334    let test_event = json!({
335        "event_type": event_type,
336        "job_id": job_id.unwrap_or_else(|| Uuid::new_v4().to_string()),
337        "queue_name": queue,
338        "priority": "Normal",
339        "timestamp": chrono::Utc::now().to_rfc3339(),
340        "test": true
341    });
342
343    info!("๐Ÿงช Testing webhook '{}' with event:", webhook_id);
344    println!("{}", serde_json::to_string_pretty(&test_event)?);
345
346    // TODO: Implement actual webhook testing by loading configuration and sending request
347    info!("โœ… Test event would be sent to webhook (implementation pending)");
348    Ok(())
349}
350
351async fn show_webhook_stats(_config: &Config, webhook: Option<String>, hours: u64) -> Result<()> {
352    // TODO: Implement webhook statistics display
353    if let Some(webhook_id) = webhook {
354        info!(
355            "๐Ÿ“Š Statistics for webhook '{}' (last {} hours):",
356            webhook_id, hours
357        );
358    } else {
359        info!("๐Ÿ“Š Statistics for all webhooks (last {} hours):", hours);
360    }
361
362    // Placeholder for webhook statistics
363    println!("Total deliveries: 0");
364    println!("Successful: 0");
365    println!("Failed: 0");
366    println!("Success rate: 0%");
367    println!("Average response time: 0ms");
368
369    Ok(())
370}
371
372async fn toggle_webhook(config: &Config, webhook_id: String, enable: bool) -> Result<()> {
373    let mut webhooks = load_webhooks_config(config)?;
374
375    let webhook = webhooks
376        .iter_mut()
377        .find(|w| w.name == webhook_id || w.id.to_string() == webhook_id);
378
379    match webhook {
380        Some(w) => {
381            w.enabled = enable;
382            save_webhooks_config(config, webhooks)?;
383            let status = if enable { "enabled" } else { "disabled" };
384            info!("โœ… Webhook '{}' {}", webhook_id, status);
385        }
386        None => {
387            error!("โŒ Webhook '{}' not found", webhook_id);
388        }
389    }
390
391    Ok(())
392}
393
394async fn update_webhook(
395    config: &Config,
396    webhook_id: String,
397    name: Option<String>,
398    url: Option<String>,
399    method: Option<String>,
400    events: Option<String>,
401    queues: Option<String>,
402    priorities: Option<String>,
403    headers: Option<String>,
404    timeout: Option<u64>,
405    max_retries: Option<u32>,
406) -> Result<()> {
407    let mut webhooks = load_webhooks_config(config)?;
408
409    let webhook = webhooks
410        .iter_mut()
411        .find(|w| w.name == webhook_id || w.id.to_string() == webhook_id);
412
413    match webhook {
414        Some(w) => {
415            if let Some(new_name) = name {
416                w.name = new_name;
417            }
418            if let Some(new_url) = url {
419                w.url = new_url;
420            }
421            if let Some(new_method) = method {
422                w.method = parse_http_method(&new_method)?;
423            }
424            if let Some(new_timeout) = timeout {
425                w.timeout = new_timeout;
426            }
427            if let Some(new_max_retries) = max_retries {
428                w.max_retries = new_max_retries;
429            }
430
431            // Update filters
432            if let Some(events_str) = events {
433                w.filter.event_types = parse_event_types(&events_str)?;
434            }
435            if let Some(queues_str) = queues {
436                w.filter.queue_names = parse_comma_separated(&queues_str);
437            }
438            if let Some(priorities_str) = priorities {
439                w.filter.priorities = parse_priorities(&priorities_str)?;
440            }
441            if let Some(headers_str) = headers {
442                w.headers = parse_headers(&headers_str)?;
443            }
444
445            save_webhooks_config(config, webhooks)?;
446            info!("โœ… Webhook '{}' updated successfully", webhook_id);
447        }
448        None => {
449            error!("โŒ Webhook '{}' not found", webhook_id);
450        }
451    }
452
453    Ok(())
454}
455
456// Helper functions for webhook configuration
457
458fn create_webhook_config(
459    name: String,
460    url: String,
461    method: String,
462    events: Option<String>,
463    queues: Option<String>,
464    priorities: Option<String>,
465    headers: Option<String>,
466    auth_token: Option<String>,
467    basic_auth: Option<String>,
468    api_key_header: Option<String>,
469    api_key: Option<String>,
470    secret: Option<String>,
471    timeout: u64,
472    max_retries: u32,
473    include_payload: bool,
474) -> Result<WebhookConfigEntry> {
475    use hammerwork::events::EventFilter;
476    use hammerwork::webhooks::{RetryPolicy, WebhookAuth};
477
478    let http_method = parse_http_method(&method)?;
479    let parsed_headers = headers
480        .map(|h| parse_headers(&h))
481        .transpose()?
482        .unwrap_or_default();
483
484    // Parse authentication
485    let auth = if let Some(token) = auth_token {
486        Some(WebhookAuth::Bearer { token })
487    } else if let Some(basic) = basic_auth {
488        let parts: Vec<&str> = basic.split(':').collect();
489        if parts.len() != 2 {
490            return Err(anyhow::anyhow!(
491                "Basic auth must be in format username:password"
492            ));
493        }
494        Some(WebhookAuth::Basic {
495            username: parts[0].to_string(),
496            password: parts[1].to_string(),
497        })
498    } else if let (Some(header), Some(key)) = (api_key_header, api_key) {
499        Some(WebhookAuth::ApiKey {
500            header_name: header,
501            api_key: key,
502        })
503    } else {
504        None
505    };
506
507    // Create event filter
508    let mut filter = EventFilter::new();
509    if let Some(events_str) = events {
510        filter.event_types = parse_event_types(&events_str)?;
511    }
512    if let Some(queues_str) = queues {
513        filter.queue_names = parse_comma_separated(&queues_str);
514    }
515    if let Some(priorities_str) = priorities {
516        filter.priorities = parse_priorities(&priorities_str)?;
517    }
518    filter.include_payload = include_payload;
519
520    let retry_policy = RetryPolicy {
521        max_attempts: max_retries,
522        initial_delay_secs: 1,
523        max_delay_secs: 300,
524        backoff_multiplier: 2.0,
525        retry_on_status_codes: vec![408, 429, 500, 502, 503, 504],
526    };
527
528    Ok(WebhookConfigEntry {
529        id: Uuid::new_v4(),
530        name,
531        url,
532        method: http_method,
533        headers: parsed_headers,
534        filter,
535        retry_policy,
536        auth,
537        timeout,
538        enabled: true,
539        secret,
540        max_retries,
541    })
542}
543
544fn parse_http_method(method: &str) -> Result<hammerwork::HttpMethod> {
545    use hammerwork::HttpMethod;
546
547    match method.to_uppercase().as_str() {
548        "POST" => Ok(HttpMethod::Post),
549        "PUT" => Ok(HttpMethod::Put),
550        "PATCH" => Ok(HttpMethod::Patch),
551        _ => Err(anyhow::anyhow!("Unsupported HTTP method: {}", method)),
552    }
553}
554
555fn parse_event_types(events_str: &str) -> Result<Vec<hammerwork::events::JobLifecycleEventType>> {
556    use hammerwork::events::JobLifecycleEventType;
557    let mut result = Vec::new();
558
559    for event in events_str.split(',') {
560        let event = event.trim();
561        let event_type = match event {
562            "enqueued" => JobLifecycleEventType::Enqueued,
563            "started" => JobLifecycleEventType::Started,
564            "completed" => JobLifecycleEventType::Completed,
565            "failed" => JobLifecycleEventType::Failed,
566            "retried" => JobLifecycleEventType::Retried,
567            "dead" => JobLifecycleEventType::Dead,
568            "timed_out" => JobLifecycleEventType::TimedOut,
569            "cancelled" => JobLifecycleEventType::Cancelled,
570            "archived" => JobLifecycleEventType::Archived,
571            "restored" => JobLifecycleEventType::Restored,
572            _ => return Err(anyhow::anyhow!("Invalid event type: {}", event)),
573        };
574        result.push(event_type);
575    }
576
577    Ok(result)
578}
579
580fn parse_priorities(priorities_str: &str) -> Result<Vec<hammerwork::priority::JobPriority>> {
581    use hammerwork::priority::JobPriority;
582    let mut result = Vec::new();
583
584    for priority in priorities_str.split(',') {
585        let priority = priority.trim();
586        let job_priority = match priority {
587            "Background" => JobPriority::Background,
588            "Low" => JobPriority::Low,
589            "Normal" => JobPriority::Normal,
590            "High" => JobPriority::High,
591            "Critical" => JobPriority::Critical,
592            _ => return Err(anyhow::anyhow!("Invalid priority: {}", priority)),
593        };
594        result.push(job_priority);
595    }
596
597    Ok(result)
598}
599
600fn parse_comma_separated(input: &str) -> Vec<String> {
601    input.split(',').map(|s| s.trim().to_string()).collect()
602}
603
604fn parse_headers(headers_str: &str) -> Result<HashMap<String, String>> {
605    let mut headers = HashMap::new();
606
607    for header in headers_str.split(',') {
608        let parts: Vec<&str> = header.split('=').collect();
609        if parts.len() != 2 {
610            return Err(anyhow::anyhow!(
611                "Header must be in format key=value: {}",
612                header
613            ));
614        }
615        headers.insert(parts[0].trim().to_string(), parts[1].trim().to_string());
616    }
617
618    Ok(headers)
619}
620
621// Configuration persistence functions
622
623#[derive(serde::Serialize, serde::Deserialize)]
624struct WebhookConfigEntry {
625    id: Uuid,
626    name: String,
627    url: String,
628    method: hammerwork::HttpMethod,
629    headers: HashMap<String, String>,
630    filter: hammerwork::events::EventFilter,
631    retry_policy: hammerwork::webhooks::RetryPolicy,
632    auth: Option<hammerwork::webhooks::WebhookAuth>,
633    timeout: u64,
634    enabled: bool,
635    secret: Option<String>,
636    max_retries: u32,
637}
638
639fn load_webhooks_config(_config: &Config) -> Result<Vec<WebhookConfigEntry>> {
640    // TODO: Implement loading from configuration file
641    Ok(Vec::new())
642}
643
644fn save_webhook_config(_config: &Config, _webhook: WebhookConfigEntry) -> Result<()> {
645    // TODO: Implement saving to configuration file
646    info!("Webhook configuration would be saved (implementation pending)");
647    Ok(())
648}
649
650fn save_webhooks_config(_config: &Config, _webhooks: Vec<WebhookConfigEntry>) -> Result<()> {
651    // TODO: Implement saving to configuration file
652    info!("Webhooks configuration would be saved (implementation pending)");
653    Ok(())
654}