1use anyhow::{Context, Result};
9use clap::Subcommand;
10use colored::Colorize;
11use raps_kernel::prompts;
12use serde::Serialize;
13
14use crate::commands::tracked::tracked_op;
15use crate::output::OutputFormat;
16use raps_webhooks::{UpdateWebhookRequest, WEBHOOK_EVENTS, WebhooksClient};
18
19#[derive(Debug, Subcommand)]
20pub enum WebhookCommands {
21 List,
23
24 Create {
26 #[arg(short, long)]
28 url: Option<String>,
29
30 #[arg(short, long)]
32 event: Option<String>,
33 },
34
35 Get {
37 #[arg(short, long, default_value = "data")]
39 system: String,
40 #[arg(short, long)]
42 event: String,
43 #[arg(long)]
45 hook_id: String,
46 },
47
48 Update {
50 #[arg(short, long, default_value = "data")]
52 system: String,
53 #[arg(short, long)]
55 event: String,
56 #[arg(long)]
58 hook_id: String,
59 #[arg(long)]
61 callback_url: Option<String>,
62 #[arg(long)]
64 status: Option<String>,
65 },
66
67 Delete {
69 hook_id: String,
71 #[arg(short, long, default_value = "data")]
73 system: String,
74 #[arg(short, long)]
76 event: String,
77 },
78
79 Events,
81
82 Test {
84 url: String,
86 #[arg(short, long, default_value = "10")]
88 timeout: u64,
89 },
90
91 #[command(name = "verify-signature")]
93 VerifySignature {
94 payload: String,
96 #[arg(short, long)]
98 signature: String,
99 #[arg(long)]
101 secret: String,
102 },
103}
104
105impl WebhookCommands {
106 pub async fn execute(self, client: &WebhooksClient, output_format: OutputFormat) -> Result<()> {
107 match self {
108 WebhookCommands::List => list_webhooks(client, output_format).await,
109 WebhookCommands::Create { url, event } => {
110 create_webhook(client, url, event, output_format).await
111 }
112 WebhookCommands::Get {
113 system,
114 event,
115 hook_id,
116 } => get_webhook(client, &system, &event, &hook_id, output_format).await,
117 WebhookCommands::Update {
118 system,
119 event,
120 hook_id,
121 callback_url,
122 status,
123 } => {
124 update_webhook(
125 client,
126 &system,
127 &event,
128 &hook_id,
129 callback_url,
130 status,
131 output_format,
132 )
133 .await
134 }
135 WebhookCommands::Delete {
136 hook_id,
137 system,
138 event,
139 } => delete_webhook(client, &system, &event, &hook_id, output_format).await,
140 WebhookCommands::Events => list_events(client, output_format),
141 WebhookCommands::Test { url, timeout } => {
142 test_webhook_endpoint(&url, timeout, output_format).await
143 }
144 WebhookCommands::VerifySignature {
145 payload,
146 signature,
147 secret,
148 } => verify_signature(&payload, &signature, &secret, output_format),
149 }
150 }
151}
152
153#[derive(Serialize)]
154struct WebhookListOutput {
155 hook_id: String,
156 event: String,
157 callback_url: String,
158 status: String,
159}
160
161async fn list_webhooks(client: &WebhooksClient, output_format: OutputFormat) -> Result<()> {
162 let webhooks = tracked_op("Fetching webhooks", output_format, || async {
163 client
164 .list_all_webhooks()
165 .await
166 .context("Failed to list webhooks. Check your authentication with 'raps auth test'")
167 })
168 .await?;
169
170 let webhook_outputs: Vec<WebhookListOutput> = webhooks
171 .iter()
172 .map(|w| WebhookListOutput {
173 hook_id: w.hook_id.clone(),
174 event: w.event.clone(),
175 callback_url: w.callback_url.clone(),
176 status: w.status.clone(),
177 })
178 .collect();
179
180 if webhook_outputs.is_empty() {
181 match output_format {
182 OutputFormat::Table => println!("{}", "No webhooks found.".yellow()),
183 _ => {
184 output_format.write(&Vec::<WebhookListOutput>::new())?;
185 }
186 }
187 return Ok(());
188 }
189
190 match output_format {
191 OutputFormat::Table => {
192 println!("\n{}", "Webhooks:".bold());
193 println!("{}", "-".repeat(90));
194 println!(
195 "{:<15} {:<25} {:<35} {}",
196 "Status".bold(),
197 "Event".bold(),
198 "Callback URL".bold(),
199 "Hook ID".bold()
200 );
201 println!("{}", "-".repeat(90));
202
203 for webhook in &webhook_outputs {
204 let status_icon = if webhook.status == "active" {
205 "active".green()
206 } else {
207 webhook.status.to_string().red()
208 };
209
210 let url = truncate_str(&webhook.callback_url, 35);
211
212 println!(
213 "{:<15} {:<25} {:<35} {}",
214 status_icon,
215 webhook.event.cyan(),
216 url,
217 webhook.hook_id.dimmed()
218 );
219 }
220
221 println!("{}", "-".repeat(90));
222 }
223 _ => {
224 output_format.write(&webhook_outputs)?;
225 }
226 }
227 Ok(())
228}
229
230#[derive(Serialize)]
231struct CreateWebhookOutput {
232 success: bool,
233 hook_id: String,
234 event: String,
235 status: String,
236 callback_url: String,
237}
238
239async fn create_webhook(
240 client: &WebhooksClient,
241 callback_url: Option<String>,
242 event: Option<String>,
243 output_format: OutputFormat,
244) -> Result<()> {
245 let url = match callback_url {
247 Some(u) => u,
248 None => prompts::input_validated("Enter callback URL", None, |input: &String| {
249 if input.starts_with("http://") || input.starts_with("https://") {
250 Ok(())
251 } else {
252 Err("URL must start with http:// or https://")
253 }
254 })?,
255 };
256
257 let event_type = match event {
259 Some(e) => {
260 if !WebhooksClient::is_valid_event(&e) {
261 let known: Vec<&str> = WEBHOOK_EVENTS.iter().map(|(e, _)| *e).collect();
262 anyhow::bail!(
263 "Unknown webhook event '{}'. Valid events: {}",
264 e,
265 known.join(", ")
266 );
267 }
268 e
269 }
270 None => {
271 let event_labels: Vec<String> = WEBHOOK_EVENTS
272 .iter()
273 .map(|(e, d)| format!("{} - {}", e, d))
274 .collect();
275
276 let selection = prompts::select("Select event type", &event_labels)?;
277 WEBHOOK_EVENTS[selection].0.to_string()
278 }
279 };
280
281 let system = if event_type.starts_with("dm.") {
283 "data"
284 } else if event_type.starts_with("extraction.") {
285 "derivative"
286 } else {
287 "data"
288 };
289
290 if output_format.supports_colors() {
291 println!("{}", "Creating webhook...".dimmed());
292 }
293
294 let webhook = client
295 .create_webhook(system, &event_type, &url, None)
296 .await
297 .context(format!(
298 "Failed to create webhook for event '{}'. Verify callback URL is reachable",
299 event_type
300 ))?;
301
302 let output = CreateWebhookOutput {
303 success: true,
304 hook_id: webhook.hook_id.clone(),
305 event: webhook.event.clone(),
306 status: webhook.status.clone(),
307 callback_url: webhook.callback_url.clone(),
308 };
309
310 match output_format {
311 OutputFormat::Table => {
312 println!("{} Webhook created successfully!", "✓".green().bold());
313 println!(" {} {}", "Hook ID:".bold(), output.hook_id);
314 println!(" {} {}", "Event:".bold(), output.event.cyan());
315 println!(" {} {}", "Status:".bold(), output.status.green());
316 println!(" {} {}", "Callback:".bold(), output.callback_url);
317 }
318 _ => {
319 output_format.write(&output)?;
320 }
321 }
322
323 Ok(())
324}
325
326#[derive(Serialize)]
327struct GetWebhookOutput {
328 hook_id: String,
329 system: String,
330 event: String,
331 callback_url: String,
332 status: String,
333 created_date: Option<String>,
334 last_updated_date: Option<String>,
335}
336
337async fn get_webhook(
338 client: &WebhooksClient,
339 system: &str,
340 event: &str,
341 hook_id: &str,
342 output_format: OutputFormat,
343) -> Result<()> {
344 if output_format.supports_colors() {
345 println!("{}", "Fetching webhook...".dimmed());
346 }
347
348 let webhook = client
349 .get_webhook(system, event, hook_id)
350 .await
351 .context(format!(
352 "Failed to get webhook '{}'. Verify the hook ID, system, and event are correct",
353 hook_id
354 ))?;
355
356 let output = GetWebhookOutput {
357 hook_id: webhook.hook_id.clone(),
358 system: webhook.system.clone(),
359 event: webhook.event.clone(),
360 callback_url: webhook.callback_url.clone(),
361 status: webhook.status.clone(),
362 created_date: webhook.created_date.clone(),
363 last_updated_date: webhook.last_updated_date.clone(),
364 };
365
366 match output_format {
367 OutputFormat::Table => {
368 println!("\n{}", "Webhook Details:".bold());
369 println!("{}", "-".repeat(60));
370 println!(" {} {}", "Hook ID:".bold(), output.hook_id);
371 println!(" {} {}", "System:".bold(), output.system);
372 println!(" {} {}", "Event:".bold(), output.event.cyan());
373 println!(" {} {}", "Callback:".bold(), output.callback_url);
374 let status_display = if output.status == "active" {
375 output.status.green().to_string()
376 } else {
377 output.status.red().to_string()
378 };
379 println!(" {} {}", "Status:".bold(), status_display);
380 if let Some(ref created) = output.created_date {
381 println!(" {} {}", "Created:".bold(), created);
382 }
383 if let Some(ref updated) = output.last_updated_date {
384 println!(" {} {}", "Updated:".bold(), updated);
385 }
386 println!("{}", "-".repeat(60));
387 }
388 _ => {
389 output_format.write(&output)?;
390 }
391 }
392 Ok(())
393}
394
395#[derive(Serialize)]
396struct UpdateWebhookOutput {
397 success: bool,
398 hook_id: String,
399 event: String,
400 status: String,
401 callback_url: String,
402}
403
404async fn update_webhook(
405 client: &WebhooksClient,
406 system: &str,
407 event: &str,
408 hook_id: &str,
409 callback_url: Option<String>,
410 status: Option<String>,
411 output_format: OutputFormat,
412) -> Result<()> {
413 if output_format.supports_colors() {
414 println!("{}", "Updating webhook...".dimmed());
415 }
416
417 let request = UpdateWebhookRequest {
418 callback_url,
419 status,
420 filter: None,
421 };
422
423 let webhook = client
424 .update_webhook(system, event, hook_id, request)
425 .await
426 .context(format!(
427 "Failed to update webhook '{}'. Verify the hook ID and permissions",
428 hook_id
429 ))?;
430
431 let output = UpdateWebhookOutput {
432 success: true,
433 hook_id: webhook.hook_id.clone(),
434 event: webhook.event.clone(),
435 status: webhook.status.clone(),
436 callback_url: webhook.callback_url.clone(),
437 };
438
439 match output_format {
440 OutputFormat::Table => {
441 println!("{} Webhook updated successfully!", "✓".green().bold());
442 println!(" {} {}", "Hook ID:".bold(), output.hook_id);
443 println!(" {} {}", "Event:".bold(), output.event.cyan());
444 println!(" {} {}", "Status:".bold(), output.status.green());
445 println!(" {} {}", "Callback:".bold(), output.callback_url);
446 }
447 _ => {
448 output_format.write(&output)?;
449 }
450 }
451
452 Ok(())
453}
454
455#[derive(Serialize)]
456struct DeleteWebhookOutput {
457 success: bool,
458 hook_id: String,
459 message: String,
460}
461
462async fn delete_webhook(
463 client: &WebhooksClient,
464 system: &str,
465 event: &str,
466 hook_id: &str,
467 output_format: OutputFormat,
468) -> Result<()> {
469 if output_format.supports_colors() {
470 println!("{}", "Deleting webhook...".dimmed());
471 }
472
473 client
474 .delete_webhook(system, event, hook_id)
475 .await
476 .context(format!(
477 "Failed to delete webhook '{}'. Verify the hook ID, system, and event are correct",
478 hook_id
479 ))?;
480
481 let output = DeleteWebhookOutput {
482 success: true,
483 hook_id: hook_id.to_string(),
484 message: "Webhook deleted successfully!".to_string(),
485 };
486
487 match output_format {
488 OutputFormat::Table => {
489 println!("{} {}", "✓".green().bold(), output.message);
490 }
491 _ => {
492 output_format.write(&output)?;
493 }
494 }
495 Ok(())
496}
497
498#[derive(Serialize)]
499struct EventOutput {
500 event: String,
501 description: String,
502}
503
504fn list_events(_client: &WebhooksClient, output_format: OutputFormat) -> Result<()> {
505 let events: Vec<EventOutput> = WEBHOOK_EVENTS
506 .iter()
507 .map(|(event, description)| EventOutput {
508 event: event.to_string(),
509 description: description.to_string(),
510 })
511 .collect();
512
513 match output_format {
514 OutputFormat::Table => {
515 println!("\n{}", "Available Webhook Events:".bold());
516 println!("{}", "-".repeat(60));
517
518 for event in &events {
519 println!(
520 " {} {}",
521 event.event.cyan(),
522 format!("- {}", event.description).dimmed()
523 );
524 }
525
526 println!("{}", "-".repeat(60));
527 }
528 _ => {
529 output_format.write(&events)?;
530 }
531 }
532 Ok(())
533}
534
535fn truncate_str(s: &str, max_len: usize) -> String {
537 if s.len() <= max_len {
538 s.to_string()
539 } else {
540 format!("{}...", &s[..max_len - 3])
541 }
542}
543
544#[derive(Serialize)]
547struct TestEndpointOutput {
548 success: bool,
549 url: String,
550 status_code: Option<u16>,
551 response_time_ms: u64,
552 message: String,
553}
554
555async fn test_webhook_endpoint(
556 url: &str,
557 timeout_secs: u64,
558 output_format: OutputFormat,
559) -> Result<()> {
560 use std::time::Instant;
561
562 if output_format.supports_colors() {
563 println!("{}", "Testing webhook endpoint...".dimmed());
564 println!(" {} {}", "URL:".bold(), url.cyan());
565 }
566
567 let test_payload = serde_json::json!({
569 "test": true,
570 "source": "raps-cli",
571 "timestamp": chrono::Utc::now().to_rfc3339()
572 });
573
574 let client = reqwest::Client::builder()
575 .timeout(std::time::Duration::from_secs(timeout_secs))
576 .build()?;
577
578 let start = Instant::now();
579
580 let result = client
581 .post(url)
582 .header("Content-Type", "application/json")
583 .header("User-Agent", "RAPS-CLI/0.7.0")
584 .json(&test_payload)
585 .send()
586 .await;
587
588 let elapsed = start.elapsed().as_millis() as u64;
589
590 let output = match result {
591 Ok(response) => {
592 let status = response.status();
593 TestEndpointOutput {
594 success: status.is_success() || status.is_redirection(),
595 url: url.to_string(),
596 status_code: Some(status.as_u16()),
597 response_time_ms: elapsed,
598 message: format!("Endpoint responded with status {}", status),
599 }
600 }
601 Err(e) => {
602 let message = if e.is_timeout() {
603 format!("Request timed out after {}s", timeout_secs)
604 } else if e.is_connect() {
605 "Failed to connect to endpoint".to_string()
606 } else {
607 format!("Request failed: {}", e)
608 };
609
610 TestEndpointOutput {
611 success: false,
612 url: url.to_string(),
613 status_code: None,
614 response_time_ms: elapsed,
615 message,
616 }
617 }
618 };
619
620 match output_format {
621 OutputFormat::Table => {
622 if output.success {
623 println!("{} Endpoint is reachable!", "✓".green().bold());
624 } else {
625 println!("{} Endpoint test failed!", "X".red().bold());
626 }
627 println!(" {} {}", "Message:".bold(), output.message);
628 if let Some(status) = output.status_code {
629 println!(" {} {}", "Status:".bold(), status);
630 }
631 println!(
632 " {} {}ms",
633 "Response time:".bold(),
634 output.response_time_ms
635 );
636 }
637 _ => {
638 output_format.write(&output)?;
639 }
640 }
641
642 Ok(())
643}
644
645#[derive(Serialize)]
646struct VerifySignatureOutput {
647 valid: bool,
648 message: String,
649}
650
651fn verify_signature(
652 payload: &str,
653 signature: &str,
654 _secret: &str,
655 output_format: OutputFormat,
656) -> Result<()> {
657 use std::io::Read;
658
659 let payload_data = if let Some(file_path) = payload.strip_prefix('@') {
661 let mut content = String::new();
662 std::fs::File::open(file_path)
663 .and_then(|mut f| f.read_to_string(&mut content))
664 .with_context(|| format!("Failed to read payload file: {}", file_path))?;
665 content
666 } else {
667 payload.to_string()
668 };
669
670 let is_valid_format = signature.len() > 20 && !signature.contains(' ');
677
678 let output = if is_valid_format {
679 VerifySignatureOutput {
680 valid: true,
681 message: "Signature format is valid. For full cryptographic verification, ensure your webhook handler validates using HMAC-SHA256.".to_string(),
682 }
683 } else {
684 VerifySignatureOutput {
685 valid: false,
686 message: "Signature format appears invalid".to_string(),
687 }
688 };
689
690 match output_format {
691 OutputFormat::Table => {
692 if output.valid {
693 println!("{} {}", "✓".green().bold(), output.message);
694 } else {
695 println!("{} {}", "X".red().bold(), output.message);
696 }
697 println!(
698 "\n{}",
699 "Tip: Use this payload in your webhook handler for testing:".dimmed()
700 );
701 println!("{}", payload_data.chars().take(200).collect::<String>());
702 if payload_data.len() > 200 {
703 println!("{}...", "".dimmed());
704 }
705 }
706 _ => {
707 output_format.write(&output)?;
708 }
709 }
710
711 Ok(())
712}