Skip to main content

tango/resources/
webhooks_api.rs

1//! Webhook management API — CRUD for endpoints and filter-based alerts.
2//!
3//! This module covers the *management* surface (`/api/webhooks/...`). Signing
4//! and verification of inbound deliveries live in the separate `tango-webhooks`
5//! crate so a receiver service doesn't have to pull the full SDK.
6
7use 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/// Body for `POST /api/webhooks/endpoints/test-delivery/`. The canonical key
20/// is `endpoint` (per tango#2252).
21#[derive(Serialize)]
22struct TestDeliveryBody<'a> {
23    endpoint: &'a str,
24}
25
26// ---------------------------------------------------------------------------
27// Webhook endpoints
28// ---------------------------------------------------------------------------
29
30impl Client {
31    /// `GET /api/webhooks/event-types/` — list the event types the server can emit.
32    pub async fn list_webhook_event_types(&self) -> Result<WebhookEventTypesResponse> {
33        self.get_json::<WebhookEventTypesResponse>("/api/webhooks/event-types/", &[])
34            .await
35    }
36
37    /// `GET /api/webhooks/endpoints/` — list the caller's configured webhook
38    /// endpoints. Pagination only — no resource-specific filters.
39    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    /// `GET /api/webhooks/endpoints/{id}/` — fetch a single endpoint.
47    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    /// `POST /api/webhooks/endpoints/` — create a new endpoint.
59    ///
60    /// `name` and `callback_url` are required. The Tango API enforces
61    /// `unique(user, name)` on endpoints, so the SDK validates client-side
62    /// for a cleaner error than the server's 400 on duplicates.
63    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    /// `PATCH /api/webhooks/endpoints/{id}/` — update an existing endpoint.
84    /// Only `Some` fields on the input are sent.
85    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    /// `DELETE /api/webhooks/endpoints/{id}/` — remove an endpoint.
101    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    /// `POST /api/webhooks/endpoints/test-delivery/` — trigger a test delivery
113    /// for `endpoint_id`. The request body uses the canonical key `endpoint`
114    /// (per tango#2252); the server still accepts the legacy `endpoint_id`
115    /// alias but the SDK sends the canonical key.
116    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    /// `GET /api/webhooks/endpoints/sample-payload/` — fetch a sample delivery
137    /// body. When `event_type` is `Some`, returns the single-event-type variant;
138    /// when `None`, the server returns samples for every event type.
139    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    // -----------------------------------------------------------------------
152    // Webhook alerts (filter-subscription convenience API)
153    // -----------------------------------------------------------------------
154
155    /// `GET /api/webhooks/alerts/` — list the caller's filter-based subscriptions.
156    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    /// `GET /api/webhooks/alerts/{id}/` — fetch a single alert.
164    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    /// `POST /api/webhooks/alerts/` — create a filter-based subscription.
176    ///
177    /// `name`, `query_type` (singular), and a non-empty `filters` object are
178    /// required. For accounts with multiple webhook endpoints, set
179    /// `input.endpoint` to the destination endpoint UUID; single-endpoint
180    /// accounts may omit it.
181    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    /// `PATCH /api/webhooks/alerts/{id}/` — update an existing alert.
216    /// Only `name`, `frequency`, `cron_expression`, and `is_active` are writable.
217    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    /// `DELETE /api/webhooks/alerts/{id}/` — remove an alert.
233    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        // Validation must trip BEFORE any HTTP call, so the unreachable base
252        // URL is fine — the request must never be issued.
253        Client::builder()
254            .api_key("k")
255            .base_url("http://localhost:1".to_string())
256            .build()
257            .expect("build client")
258    }
259
260    // ---- endpoints: empty-id rejection ----
261
262    #[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    // ---- create endpoint: required fields ----
290
291    #[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    // ---- alerts: empty-id rejection ----
320
321    #[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    // ---- create alert: required fields + filters shape ----
343
344    #[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}