Skip to main content

raps_cli/commands/
webhook.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2025 Dmytro Yemelianov
3
4//! Webhook management commands
5//!
6//! Commands for creating, listing, and deleting webhook subscriptions.
7
8use 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;
16// use raps_kernel::output::OutputFormat;
17use raps_webhooks::{UpdateWebhookRequest, WEBHOOK_EVENTS, WebhooksClient};
18
19#[derive(Debug, Subcommand)]
20pub enum WebhookCommands {
21    /// List all webhooks
22    List,
23
24    /// Create a new webhook subscription
25    Create {
26        /// Callback URL for webhook notifications
27        #[arg(short, long)]
28        url: Option<String>,
29
30        /// Event type (e.g., dm.version.added)
31        #[arg(short, long)]
32        event: Option<String>,
33    },
34
35    /// Get a specific webhook
36    Get {
37        /// System (e.g., data)
38        #[arg(short, long, default_value = "data")]
39        system: String,
40        /// Event type
41        #[arg(short, long)]
42        event: String,
43        /// Hook ID
44        #[arg(long)]
45        hook_id: String,
46    },
47
48    /// Update a webhook
49    Update {
50        /// System (e.g., data)
51        #[arg(short, long, default_value = "data")]
52        system: String,
53        /// Event type
54        #[arg(short, long)]
55        event: String,
56        /// Hook ID
57        #[arg(long)]
58        hook_id: String,
59        /// New callback URL
60        #[arg(long)]
61        callback_url: Option<String>,
62        /// New status (active or inactive)
63        #[arg(long)]
64        status: Option<String>,
65    },
66
67    /// Delete a webhook
68    Delete {
69        /// Hook ID to delete
70        hook_id: String,
71        /// System (e.g., data)
72        #[arg(short, long, default_value = "data")]
73        system: String,
74        /// Event type
75        #[arg(short, long)]
76        event: String,
77    },
78
79    /// List available webhook events
80    Events,
81
82    /// Test webhook endpoint connectivity
83    Test {
84        /// Webhook callback URL to test
85        url: String,
86        /// Timeout in seconds (default: 10)
87        #[arg(short, long, default_value = "10")]
88        timeout: u64,
89    },
90
91    /// Verify webhook signature
92    #[command(name = "verify-signature")]
93    VerifySignature {
94        /// The webhook payload (JSON string or @file)
95        payload: String,
96        /// The signature from X-Adsk-Signature header
97        #[arg(short, long)]
98        signature: String,
99        /// The webhook secret
100        #[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    // Get callback URL
246    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    // Get event type
258    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    // Determine system from event
282    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
535/// Truncate string with ellipsis
536fn 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// ============== WEBHOOK TESTING ==============
545
546#[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    // Create a simple test payload
568    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    // Load payload (from string or file)
660    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    // Calculate HMAC-SHA256 signature
671    // Note: In a real implementation, you'd use a crypto library like hmac + sha2
672    // For now, we'll provide a placeholder that shows the expected format
673
674    // The APS webhook signature format is typically base64(HMAC-SHA256(secret, payload))
675    // This is a simplified verification that checks format
676    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}