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 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_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 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 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 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 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 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
456fn 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 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 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#[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 Ok(Vec::new())
642}
643
644fn save_webhook_config(_config: &Config, _webhook: WebhookConfigEntry) -> Result<()> {
645 info!("Webhook configuration would be saved (implementation pending)");
647 Ok(())
648}
649
650fn save_webhooks_config(_config: &Config, _webhooks: Vec<WebhookConfigEntry>) -> Result<()> {
651 info!("Webhooks configuration would be saved (implementation pending)");
653 Ok(())
654}