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        self.get_page("/api/webhooks/endpoints/", &q).await
43    }
44
45    /// `GET /api/webhooks/endpoints/{id}/` — fetch a single endpoint.
46    pub async fn get_webhook_endpoint(&self, id: &str) -> Result<WebhookEndpoint> {
47        if id.is_empty() {
48            return Err(Error::Validation {
49                message: "GetWebhookEndpoint: id is required".into(),
50                response: None,
51            });
52        }
53        let path = format!("/api/webhooks/endpoints/{}/", urlencoding(id));
54        self.get_json::<WebhookEndpoint>(&path, &[]).await
55    }
56
57    /// `POST /api/webhooks/endpoints/` — create a new endpoint.
58    ///
59    /// `name` and `callback_url` are required. The Tango API enforces
60    /// `unique(user, name)` on endpoints, so the SDK validates client-side
61    /// for a cleaner error than the server's 400 on duplicates.
62    pub async fn create_webhook_endpoint(
63        &self,
64        input: WebhookEndpointCreateInput,
65    ) -> Result<WebhookEndpoint> {
66        if input.name.is_empty() {
67            return Err(Error::Validation {
68                message: "CreateWebhookEndpoint: name is required (the Tango API enforces unique(user, name) on endpoints)".into(),
69                response: None,
70            });
71        }
72        if input.callback_url.is_empty() {
73            return Err(Error::Validation {
74                message: "CreateWebhookEndpoint: callback_url is required".into(),
75                response: None,
76            });
77        }
78        self.post_json::<_, WebhookEndpoint>("/api/webhooks/endpoints/", &input)
79            .await
80    }
81
82    /// `PATCH /api/webhooks/endpoints/{id}/` — update an existing endpoint.
83    /// Only `Some` fields on the input are sent.
84    pub async fn update_webhook_endpoint(
85        &self,
86        id: &str,
87        input: WebhookEndpointUpdateInput,
88    ) -> Result<WebhookEndpoint> {
89        if id.is_empty() {
90            return Err(Error::Validation {
91                message: "UpdateWebhookEndpoint: id is required".into(),
92                response: None,
93            });
94        }
95        let path = format!("/api/webhooks/endpoints/{}/", urlencoding(id));
96        self.patch_json::<_, WebhookEndpoint>(&path, &input).await
97    }
98
99    /// `DELETE /api/webhooks/endpoints/{id}/` — remove an endpoint.
100    pub async fn delete_webhook_endpoint(&self, id: &str) -> Result<()> {
101        if id.is_empty() {
102            return Err(Error::Validation {
103                message: "DeleteWebhookEndpoint: id is required".into(),
104                response: None,
105            });
106        }
107        let path = format!("/api/webhooks/endpoints/{}/", urlencoding(id));
108        self.delete_no_content(&path).await
109    }
110
111    /// `POST /api/webhooks/endpoints/test-delivery/` — trigger a test delivery
112    /// for `endpoint_id`. The request body uses the canonical key `endpoint`
113    /// (per tango#2252); the server still accepts the legacy `endpoint_id`
114    /// alias but the SDK sends the canonical key.
115    pub async fn test_webhook_endpoint(
116        &self,
117        endpoint_id: &str,
118    ) -> Result<WebhookTestDeliveryResult> {
119        if endpoint_id.is_empty() {
120            return Err(Error::Validation {
121                message: "TestWebhookEndpoint: endpoint_id is required".into(),
122                response: None,
123            });
124        }
125        let body = TestDeliveryBody {
126            endpoint: endpoint_id,
127        };
128        self.post_json::<_, WebhookTestDeliveryResult>(
129            "/api/webhooks/endpoints/test-delivery/",
130            &body,
131        )
132        .await
133    }
134
135    /// `GET /api/webhooks/endpoints/sample-payload/` — fetch a sample delivery
136    /// body. When `event_type` is `Some`, returns the single-event-type variant;
137    /// when `None`, the server returns samples for every event type.
138    pub async fn get_webhook_sample_payload(
139        &self,
140        event_type: Option<&str>,
141    ) -> Result<WebhookSamplePayloadResponse> {
142        let mut q = Vec::new();
143        if let Some(ev) = event_type.filter(|s| !s.is_empty()) {
144            q.push(("event_type".into(), ev.into()));
145        }
146        self.get_json::<WebhookSamplePayloadResponse>("/api/webhooks/endpoints/sample-payload/", &q)
147            .await
148    }
149
150    // -----------------------------------------------------------------------
151    // Webhook alerts (filter-subscription convenience API)
152    // -----------------------------------------------------------------------
153
154    /// `GET /api/webhooks/alerts/` — list the caller's filter-based subscriptions.
155    pub async fn list_webhook_alerts(&self, opts: ListOptions) -> Result<Page<WebhookAlert>> {
156        let mut q = Vec::new();
157        opts.apply(&mut q);
158        self.get_page("/api/webhooks/alerts/", &q).await
159    }
160
161    /// `GET /api/webhooks/alerts/{id}/` — fetch a single alert.
162    pub async fn get_webhook_alert(&self, id: &str) -> Result<WebhookAlert> {
163        if id.is_empty() {
164            return Err(Error::Validation {
165                message: "GetWebhookAlert: id is required".into(),
166                response: None,
167            });
168        }
169        let path = format!("/api/webhooks/alerts/{}/", urlencoding(id));
170        self.get_json::<WebhookAlert>(&path, &[]).await
171    }
172
173    /// `POST /api/webhooks/alerts/` — create a filter-based subscription.
174    ///
175    /// `name`, `query_type` (singular), and a non-empty `filters` object are
176    /// required. For accounts with multiple webhook endpoints, set
177    /// `input.endpoint` to the destination endpoint UUID; single-endpoint
178    /// accounts may omit it.
179    pub async fn create_webhook_alert(
180        &self,
181        input: WebhookAlertCreateInput,
182    ) -> Result<WebhookAlert> {
183        if input.name.is_empty() {
184            return Err(Error::Validation {
185                message: "CreateWebhookAlert: name is required".into(),
186                response: None,
187            });
188        }
189        if input.query_type.is_empty() {
190            return Err(Error::Validation {
191                message:
192                    r#"CreateWebhookAlert: query_type is required (singular, e.g. "contract")"#
193                        .into(),
194                response: None,
195            });
196        }
197        let empty = match &input.filters {
198            serde_json::Value::Null => true,
199            serde_json::Value::Object(m) => m.is_empty(),
200            serde_json::Value::Array(a) => a.is_empty(),
201            _ => false,
202        };
203        if empty {
204            return Err(Error::Validation {
205                message: "CreateWebhookAlert: filters must be a non-empty object".into(),
206                response: None,
207            });
208        }
209        self.post_json::<_, WebhookAlert>("/api/webhooks/alerts/", &input)
210            .await
211    }
212
213    /// `PATCH /api/webhooks/alerts/{id}/` — update an existing alert.
214    /// Only `name`, `frequency`, `cron_expression`, and `is_active` are writable.
215    pub async fn update_webhook_alert(
216        &self,
217        id: &str,
218        input: WebhookAlertUpdateInput,
219    ) -> Result<WebhookAlert> {
220        if id.is_empty() {
221            return Err(Error::Validation {
222                message: "UpdateWebhookAlert: id is required".into(),
223                response: None,
224            });
225        }
226        let path = format!("/api/webhooks/alerts/{}/", urlencoding(id));
227        self.patch_json::<_, WebhookAlert>(&path, &input).await
228    }
229
230    /// `DELETE /api/webhooks/alerts/{id}/` — remove an alert.
231    pub async fn delete_webhook_alert(&self, id: &str) -> Result<()> {
232        if id.is_empty() {
233            return Err(Error::Validation {
234                message: "DeleteWebhookAlert: id is required".into(),
235                response: None,
236            });
237        }
238        let path = format!("/api/webhooks/alerts/{}/", urlencoding(id));
239        self.delete_no_content(&path).await
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use serde_json::json;
247
248    fn client() -> Client {
249        // Validation must trip BEFORE any HTTP call, so the unreachable base
250        // URL is fine — the request must never be issued.
251        Client::builder()
252            .api_key("k")
253            .base_url("http://localhost:1".to_string())
254            .build()
255            .expect("build client")
256    }
257
258    // ---- endpoints: empty-id rejection ----
259
260    #[tokio::test]
261    async fn get_endpoint_rejects_empty_id() {
262        let err = client().get_webhook_endpoint("").await.unwrap_err();
263        assert!(matches!(err, Error::Validation { .. }));
264    }
265
266    #[tokio::test]
267    async fn update_endpoint_rejects_empty_id() {
268        let err = client()
269            .update_webhook_endpoint("", WebhookEndpointUpdateInput::default())
270            .await
271            .unwrap_err();
272        assert!(matches!(err, Error::Validation { .. }));
273    }
274
275    #[tokio::test]
276    async fn delete_endpoint_rejects_empty_id() {
277        let err = client().delete_webhook_endpoint("").await.unwrap_err();
278        assert!(matches!(err, Error::Validation { .. }));
279    }
280
281    #[tokio::test]
282    async fn test_endpoint_rejects_empty_id() {
283        let err = client().test_webhook_endpoint("").await.unwrap_err();
284        assert!(matches!(err, Error::Validation { .. }));
285    }
286
287    // ---- create endpoint: required fields ----
288
289    #[tokio::test]
290    async fn create_endpoint_rejects_empty_name() {
291        let err = client()
292            .create_webhook_endpoint(WebhookEndpointCreateInput {
293                name: String::new(),
294                callback_url: "https://example.com/hook".into(),
295                is_active: None,
296                event_types: vec![],
297            })
298            .await
299            .unwrap_err();
300        assert!(matches!(err, Error::Validation { .. }));
301    }
302
303    #[tokio::test]
304    async fn create_endpoint_rejects_empty_callback_url() {
305        let err = client()
306            .create_webhook_endpoint(WebhookEndpointCreateInput {
307                name: "my-hook".into(),
308                callback_url: String::new(),
309                is_active: None,
310                event_types: vec![],
311            })
312            .await
313            .unwrap_err();
314        assert!(matches!(err, Error::Validation { .. }));
315    }
316
317    // ---- alerts: empty-id rejection ----
318
319    #[tokio::test]
320    async fn get_alert_rejects_empty_id() {
321        let err = client().get_webhook_alert("").await.unwrap_err();
322        assert!(matches!(err, Error::Validation { .. }));
323    }
324
325    #[tokio::test]
326    async fn update_alert_rejects_empty_id() {
327        let err = client()
328            .update_webhook_alert("", WebhookAlertUpdateInput::default())
329            .await
330            .unwrap_err();
331        assert!(matches!(err, Error::Validation { .. }));
332    }
333
334    #[tokio::test]
335    async fn delete_alert_rejects_empty_id() {
336        let err = client().delete_webhook_alert("").await.unwrap_err();
337        assert!(matches!(err, Error::Validation { .. }));
338    }
339
340    // ---- create alert: required fields + filters shape ----
341
342    #[tokio::test]
343    async fn create_alert_rejects_empty_name() {
344        let err = client()
345            .create_webhook_alert(WebhookAlertCreateInput {
346                name: String::new(),
347                query_type: "contract".into(),
348                filters: json!({"piid": "X"}),
349                frequency: None,
350                cron_expression: None,
351                endpoint: None,
352            })
353            .await
354            .unwrap_err();
355        assert!(matches!(err, Error::Validation { .. }));
356    }
357
358    #[tokio::test]
359    async fn create_alert_rejects_empty_query_type() {
360        let err = client()
361            .create_webhook_alert(WebhookAlertCreateInput {
362                name: "n".into(),
363                query_type: String::new(),
364                filters: json!({"piid": "X"}),
365                frequency: None,
366                cron_expression: None,
367                endpoint: None,
368            })
369            .await
370            .unwrap_err();
371        assert!(matches!(err, Error::Validation { .. }));
372    }
373
374    #[tokio::test]
375    async fn create_alert_rejects_null_filters() {
376        let err = client()
377            .create_webhook_alert(WebhookAlertCreateInput {
378                name: "n".into(),
379                query_type: "contract".into(),
380                filters: serde_json::Value::Null,
381                frequency: None,
382                cron_expression: None,
383                endpoint: None,
384            })
385            .await
386            .unwrap_err();
387        assert!(matches!(err, Error::Validation { .. }));
388    }
389
390    #[tokio::test]
391    async fn create_alert_rejects_empty_object_filters() {
392        let err = client()
393            .create_webhook_alert(WebhookAlertCreateInput {
394                name: "n".into(),
395                query_type: "contract".into(),
396                filters: json!({}),
397                frequency: None,
398                cron_expression: None,
399                endpoint: None,
400            })
401            .await
402            .unwrap_err();
403        assert!(matches!(err, Error::Validation { .. }));
404    }
405
406    #[tokio::test]
407    async fn create_alert_rejects_empty_array_filters() {
408        let err = client()
409            .create_webhook_alert(WebhookAlertCreateInput {
410                name: "n".into(),
411                query_type: "contract".into(),
412                filters: json!([]),
413                frequency: None,
414                cron_expression: None,
415                endpoint: None,
416            })
417            .await
418            .unwrap_err();
419        assert!(matches!(err, Error::Validation { .. }));
420    }
421}