1use crate::client::Client;
8use crate::error::{Error, Result};
9use crate::models::{
10 WebhookAlert, WebhookAlertCreateInput, WebhookAlertUpdateInput, WebhookEndpoint,
11 WebhookEndpointCreateInput, WebhookEndpointUpdateInput, WebhookEventTypesResponse,
12 WebhookSamplePayloadResponse, WebhookTestDeliveryResult,
13};
14use crate::pagination::Page;
15use crate::resources::agencies::urlencoding;
16use crate::ListOptions;
17use serde::Serialize;
18
19#[derive(Serialize)]
22struct TestDeliveryBody<'a> {
23 endpoint: &'a str,
24}
25
26impl Client {
31 pub async fn list_webhook_event_types(&self) -> Result<WebhookEventTypesResponse> {
33 self.get_json::<WebhookEventTypesResponse>("/api/webhooks/event-types/", &[])
34 .await
35 }
36
37 pub async fn list_webhook_endpoints(&self, opts: ListOptions) -> Result<Page<WebhookEndpoint>> {
40 let mut q = Vec::new();
41 opts.apply(&mut q);
42 let bytes = self.get_bytes("/api/webhooks/endpoints/", &q).await?;
43 Page::<WebhookEndpoint>::decode(&bytes)
44 }
45
46 pub async fn get_webhook_endpoint(&self, id: &str) -> Result<WebhookEndpoint> {
48 if id.is_empty() {
49 return Err(Error::Validation {
50 message: "GetWebhookEndpoint: id is required".into(),
51 response: None,
52 });
53 }
54 let path = format!("/api/webhooks/endpoints/{}/", urlencoding(id));
55 self.get_json::<WebhookEndpoint>(&path, &[]).await
56 }
57
58 pub async fn create_webhook_endpoint(
64 &self,
65 input: WebhookEndpointCreateInput,
66 ) -> Result<WebhookEndpoint> {
67 if input.name.is_empty() {
68 return Err(Error::Validation {
69 message: "CreateWebhookEndpoint: name is required (the Tango API enforces unique(user, name) on endpoints)".into(),
70 response: None,
71 });
72 }
73 if input.callback_url.is_empty() {
74 return Err(Error::Validation {
75 message: "CreateWebhookEndpoint: callback_url is required".into(),
76 response: None,
77 });
78 }
79 self.post_json::<_, WebhookEndpoint>("/api/webhooks/endpoints/", &input)
80 .await
81 }
82
83 pub async fn update_webhook_endpoint(
86 &self,
87 id: &str,
88 input: WebhookEndpointUpdateInput,
89 ) -> Result<WebhookEndpoint> {
90 if id.is_empty() {
91 return Err(Error::Validation {
92 message: "UpdateWebhookEndpoint: id is required".into(),
93 response: None,
94 });
95 }
96 let path = format!("/api/webhooks/endpoints/{}/", urlencoding(id));
97 self.patch_json::<_, WebhookEndpoint>(&path, &input).await
98 }
99
100 pub async fn delete_webhook_endpoint(&self, id: &str) -> Result<()> {
102 if id.is_empty() {
103 return Err(Error::Validation {
104 message: "DeleteWebhookEndpoint: id is required".into(),
105 response: None,
106 });
107 }
108 let path = format!("/api/webhooks/endpoints/{}/", urlencoding(id));
109 self.delete_no_content(&path).await
110 }
111
112 pub async fn test_webhook_endpoint(
117 &self,
118 endpoint_id: &str,
119 ) -> Result<WebhookTestDeliveryResult> {
120 if endpoint_id.is_empty() {
121 return Err(Error::Validation {
122 message: "TestWebhookEndpoint: endpoint_id is required".into(),
123 response: None,
124 });
125 }
126 let body = TestDeliveryBody {
127 endpoint: endpoint_id,
128 };
129 self.post_json::<_, WebhookTestDeliveryResult>(
130 "/api/webhooks/endpoints/test-delivery/",
131 &body,
132 )
133 .await
134 }
135
136 pub async fn get_webhook_sample_payload(
140 &self,
141 event_type: Option<&str>,
142 ) -> Result<WebhookSamplePayloadResponse> {
143 let mut q = Vec::new();
144 if let Some(ev) = event_type.filter(|s| !s.is_empty()) {
145 q.push(("event_type".into(), ev.into()));
146 }
147 self.get_json::<WebhookSamplePayloadResponse>("/api/webhooks/endpoints/sample-payload/", &q)
148 .await
149 }
150
151 pub async fn list_webhook_alerts(&self, opts: ListOptions) -> Result<Page<WebhookAlert>> {
157 let mut q = Vec::new();
158 opts.apply(&mut q);
159 let bytes = self.get_bytes("/api/webhooks/alerts/", &q).await?;
160 Page::<WebhookAlert>::decode(&bytes)
161 }
162
163 pub async fn get_webhook_alert(&self, id: &str) -> Result<WebhookAlert> {
165 if id.is_empty() {
166 return Err(Error::Validation {
167 message: "GetWebhookAlert: id is required".into(),
168 response: None,
169 });
170 }
171 let path = format!("/api/webhooks/alerts/{}/", urlencoding(id));
172 self.get_json::<WebhookAlert>(&path, &[]).await
173 }
174
175 pub async fn create_webhook_alert(
182 &self,
183 input: WebhookAlertCreateInput,
184 ) -> Result<WebhookAlert> {
185 if input.name.is_empty() {
186 return Err(Error::Validation {
187 message: "CreateWebhookAlert: name is required".into(),
188 response: None,
189 });
190 }
191 if input.query_type.is_empty() {
192 return Err(Error::Validation {
193 message:
194 r#"CreateWebhookAlert: query_type is required (singular, e.g. "contract")"#
195 .into(),
196 response: None,
197 });
198 }
199 let empty = match &input.filters {
200 serde_json::Value::Null => true,
201 serde_json::Value::Object(m) => m.is_empty(),
202 serde_json::Value::Array(a) => a.is_empty(),
203 _ => false,
204 };
205 if empty {
206 return Err(Error::Validation {
207 message: "CreateWebhookAlert: filters must be a non-empty object".into(),
208 response: None,
209 });
210 }
211 self.post_json::<_, WebhookAlert>("/api/webhooks/alerts/", &input)
212 .await
213 }
214
215 pub async fn update_webhook_alert(
218 &self,
219 id: &str,
220 input: WebhookAlertUpdateInput,
221 ) -> Result<WebhookAlert> {
222 if id.is_empty() {
223 return Err(Error::Validation {
224 message: "UpdateWebhookAlert: id is required".into(),
225 response: None,
226 });
227 }
228 let path = format!("/api/webhooks/alerts/{}/", urlencoding(id));
229 self.patch_json::<_, WebhookAlert>(&path, &input).await
230 }
231
232 pub async fn delete_webhook_alert(&self, id: &str) -> Result<()> {
234 if id.is_empty() {
235 return Err(Error::Validation {
236 message: "DeleteWebhookAlert: id is required".into(),
237 response: None,
238 });
239 }
240 let path = format!("/api/webhooks/alerts/{}/", urlencoding(id));
241 self.delete_no_content(&path).await
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248 use serde_json::json;
249
250 fn client() -> Client {
251 Client::builder()
254 .api_key("k")
255 .base_url("http://localhost:1".to_string())
256 .build()
257 .expect("build client")
258 }
259
260 #[tokio::test]
263 async fn get_endpoint_rejects_empty_id() {
264 let err = client().get_webhook_endpoint("").await.unwrap_err();
265 assert!(matches!(err, Error::Validation { .. }));
266 }
267
268 #[tokio::test]
269 async fn update_endpoint_rejects_empty_id() {
270 let err = client()
271 .update_webhook_endpoint("", WebhookEndpointUpdateInput::default())
272 .await
273 .unwrap_err();
274 assert!(matches!(err, Error::Validation { .. }));
275 }
276
277 #[tokio::test]
278 async fn delete_endpoint_rejects_empty_id() {
279 let err = client().delete_webhook_endpoint("").await.unwrap_err();
280 assert!(matches!(err, Error::Validation { .. }));
281 }
282
283 #[tokio::test]
284 async fn test_endpoint_rejects_empty_id() {
285 let err = client().test_webhook_endpoint("").await.unwrap_err();
286 assert!(matches!(err, Error::Validation { .. }));
287 }
288
289 #[tokio::test]
292 async fn create_endpoint_rejects_empty_name() {
293 let err = client()
294 .create_webhook_endpoint(WebhookEndpointCreateInput {
295 name: String::new(),
296 callback_url: "https://example.com/hook".into(),
297 is_active: None,
298 event_types: vec![],
299 })
300 .await
301 .unwrap_err();
302 assert!(matches!(err, Error::Validation { .. }));
303 }
304
305 #[tokio::test]
306 async fn create_endpoint_rejects_empty_callback_url() {
307 let err = client()
308 .create_webhook_endpoint(WebhookEndpointCreateInput {
309 name: "my-hook".into(),
310 callback_url: String::new(),
311 is_active: None,
312 event_types: vec![],
313 })
314 .await
315 .unwrap_err();
316 assert!(matches!(err, Error::Validation { .. }));
317 }
318
319 #[tokio::test]
322 async fn get_alert_rejects_empty_id() {
323 let err = client().get_webhook_alert("").await.unwrap_err();
324 assert!(matches!(err, Error::Validation { .. }));
325 }
326
327 #[tokio::test]
328 async fn update_alert_rejects_empty_id() {
329 let err = client()
330 .update_webhook_alert("", WebhookAlertUpdateInput::default())
331 .await
332 .unwrap_err();
333 assert!(matches!(err, Error::Validation { .. }));
334 }
335
336 #[tokio::test]
337 async fn delete_alert_rejects_empty_id() {
338 let err = client().delete_webhook_alert("").await.unwrap_err();
339 assert!(matches!(err, Error::Validation { .. }));
340 }
341
342 #[tokio::test]
345 async fn create_alert_rejects_empty_name() {
346 let err = client()
347 .create_webhook_alert(WebhookAlertCreateInput {
348 name: String::new(),
349 query_type: "contract".into(),
350 filters: json!({"piid": "X"}),
351 frequency: None,
352 cron_expression: None,
353 endpoint: None,
354 })
355 .await
356 .unwrap_err();
357 assert!(matches!(err, Error::Validation { .. }));
358 }
359
360 #[tokio::test]
361 async fn create_alert_rejects_empty_query_type() {
362 let err = client()
363 .create_webhook_alert(WebhookAlertCreateInput {
364 name: "n".into(),
365 query_type: String::new(),
366 filters: json!({"piid": "X"}),
367 frequency: None,
368 cron_expression: None,
369 endpoint: None,
370 })
371 .await
372 .unwrap_err();
373 assert!(matches!(err, Error::Validation { .. }));
374 }
375
376 #[tokio::test]
377 async fn create_alert_rejects_null_filters() {
378 let err = client()
379 .create_webhook_alert(WebhookAlertCreateInput {
380 name: "n".into(),
381 query_type: "contract".into(),
382 filters: serde_json::Value::Null,
383 frequency: None,
384 cron_expression: None,
385 endpoint: None,
386 })
387 .await
388 .unwrap_err();
389 assert!(matches!(err, Error::Validation { .. }));
390 }
391
392 #[tokio::test]
393 async fn create_alert_rejects_empty_object_filters() {
394 let err = client()
395 .create_webhook_alert(WebhookAlertCreateInput {
396 name: "n".into(),
397 query_type: "contract".into(),
398 filters: json!({}),
399 frequency: None,
400 cron_expression: None,
401 endpoint: None,
402 })
403 .await
404 .unwrap_err();
405 assert!(matches!(err, Error::Validation { .. }));
406 }
407
408 #[tokio::test]
409 async fn create_alert_rejects_empty_array_filters() {
410 let err = client()
411 .create_webhook_alert(WebhookAlertCreateInput {
412 name: "n".into(),
413 query_type: "contract".into(),
414 filters: json!([]),
415 frequency: None,
416 cron_expression: None,
417 endpoint: None,
418 })
419 .await
420 .unwrap_err();
421 assert!(matches!(err, Error::Validation { .. }));
422 }
423}