Skip to main content

max / makenotwork

4.0 KB · 134 lines History Blame Raw
1 //! HTTP client for the WAM (Whack-a-Mole) ticket manager.
2 //!
3 //! WAM runs on the tailnet — no auth required. The MNW server creates tickets
4 //! for operational events that need human attention (stale refunds, dead
5 //! webhooks, etc.).
6
7 use serde::Serialize;
8
9 /// WAM ticket creation client.
10 #[derive(Clone)]
11 pub struct WamClient {
12 http: reqwest::Client,
13 base_url: String,
14 }
15
16 #[derive(Serialize)]
17 struct CreateTicketRequest<'a> {
18 title: &'a str,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 body: Option<&'a str>,
21 priority: &'a str,
22 source: &'a str,
23 #[serde(skip_serializing_if = "Option::is_none")]
24 source_ref: Option<&'a str>,
25 }
26
27 impl WamClient {
28 pub fn new(base_url: String) -> Self {
29 let http = reqwest::Client::builder()
30 .timeout(std::time::Duration::from_secs(5))
31 .connect_timeout(std::time::Duration::from_secs(3))
32 .build()
33 .expect("WAM HTTP client");
34 Self { http, base_url }
35 }
36
37 /// Return the ticket endpoint URL.
38 pub fn ticket_url(&self) -> String {
39 format!("{}/tickets", self.base_url.trim_end_matches('/'))
40 }
41
42 /// Create a ticket in WAM. Errors are logged but never propagated — WAM
43 /// is a best-effort notification channel, not a critical path.
44 pub async fn create_ticket(
45 &self,
46 title: &str,
47 body: Option<&str>,
48 priority: &str,
49 source: &str,
50 source_ref: Option<&str>,
51 ) {
52 let url = self.ticket_url();
53 let req = CreateTicketRequest {
54 title,
55 body,
56 priority,
57 source,
58 source_ref,
59 };
60
61 match self.http.post(&url).json(&req).send().await {
62 Ok(resp) if resp.status().is_success() => {
63 tracing::info!(title, source, "WAM ticket created");
64 }
65 Ok(resp) => {
66 tracing::warn!(
67 status = %resp.status(), title, source,
68 "WAM ticket creation returned non-success"
69 );
70 }
71 Err(e) => {
72 tracing::warn!(error = %e, title, source, "WAM unreachable");
73 }
74 }
75 }
76 }
77
78 #[cfg(test)]
79 mod tests {
80 use super::*;
81
82 #[test]
83 fn ticket_url_construction() {
84 let client = WamClient::new("http://100.120.174.96:7890".to_string());
85 assert_eq!(client.ticket_url(), "http://100.120.174.96:7890/tickets");
86 }
87
88 #[test]
89 fn ticket_url_strips_trailing_slash() {
90 let client = WamClient::new("http://localhost:7890/".to_string());
91 assert_eq!(client.ticket_url(), "http://localhost:7890/tickets");
92 }
93
94 #[test]
95 fn request_serialization_full() {
96 let req = CreateTicketRequest {
97 title: "Test ticket",
98 body: Some("Details here"),
99 priority: "high",
100 source: "test-source",
101 source_ref: Some("ref-123"),
102 };
103 let json = serde_json::to_value(&req).unwrap();
104 assert_eq!(json["title"], "Test ticket");
105 assert_eq!(json["body"], "Details here");
106 assert_eq!(json["priority"], "high");
107 assert_eq!(json["source"], "test-source");
108 assert_eq!(json["source_ref"], "ref-123");
109 }
110
111 #[test]
112 fn request_serialization_skips_none_fields() {
113 let req = CreateTicketRequest {
114 title: "Minimal",
115 body: None,
116 priority: "low",
117 source: "test",
118 source_ref: None,
119 };
120 let json = serde_json::to_value(&req).unwrap();
121 assert_eq!(json["title"], "Minimal");
122 assert!(json.get("body").is_none());
123 assert!(json.get("source_ref").is_none());
124 }
125
126 #[tokio::test]
127 async fn create_ticket_unreachable_does_not_panic() {
128 // WAM is fire-and-forget — unreachable server should not panic
129 let client = WamClient::new("http://127.0.0.1:1".to_string());
130 client.create_ticket("test", None, "low", "test", None).await;
131 // If we get here, the error was swallowed correctly
132 }
133 }
134