Skip to main content

rust_genai/
webhooks.rs

1//! Webhooks API surface.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use reqwest::header::{HeaderName, HeaderValue};
7use rust_genai_types::webhooks::{
8    CreateWebhookConfig, DeleteWebhookConfig, GetWebhookConfig, ListWebhooksConfig,
9    PingWebhookConfig, RotateWebhookSigningSecretConfig, UpdateWebhookConfig, Webhook,
10    WebhookDeleteResponse, WebhookListResponse, WebhookPingResponse,
11    WebhookRotateSigningSecretResponse,
12};
13
14use crate::client::{Backend, ClientInner};
15use crate::error::{Error, Result};
16
17#[derive(Clone)]
18pub struct Webhooks {
19    pub(crate) inner: Arc<ClientInner>,
20}
21
22impl Webhooks {
23    pub(crate) const fn new(inner: Arc<ClientInner>) -> Self {
24        Self { inner }
25    }
26
27    /// Creates a webhook.
28    ///
29    /// # Errors
30    /// Returns an error when the request fails or the config is invalid.
31    pub async fn create(&self, mut config: CreateWebhookConfig) -> Result<Webhook> {
32        ensure_gemini_backend(&self.inner)?;
33        validate_create_config(&config)?;
34        let http_options = config.http_options.take();
35        let url = add_create_query_params(
36            &build_webhooks_url(&self.inner, http_options.as_ref()),
37            config.webhook_id.as_deref(),
38        )?;
39        let mut request = self.inner.http.post(url).json(&config);
40        request = apply_http_options(request, http_options.as_ref())?;
41
42        let response = self
43            .inner
44            .send_with_http_options(request, http_options.as_ref())
45            .await?;
46        if !response.status().is_success() {
47            return Err(Error::api_error_from_response(response, None).await);
48        }
49        parse_json_or_default::<Webhook>(response).await
50    }
51
52    /// Updates a webhook.
53    ///
54    /// # Errors
55    /// Returns an error when the request fails or the config is invalid.
56    pub async fn update(
57        &self,
58        id: impl AsRef<str>,
59        mut config: UpdateWebhookConfig,
60    ) -> Result<Webhook> {
61        ensure_gemini_backend(&self.inner)?;
62        validate_update_config(&config)?;
63        let http_options = config.http_options.take();
64        let name = normalize_webhook_name(id.as_ref());
65        let url = add_update_query_params(
66            &build_webhook_url(&self.inner, &name, http_options.as_ref()),
67            config.update_mask.as_deref(),
68        )?;
69        let mut request = self.inner.http.patch(url).json(&config);
70        request = apply_http_options(request, http_options.as_ref())?;
71
72        let response = self
73            .inner
74            .send_with_http_options(request, http_options.as_ref())
75            .await?;
76        if !response.status().is_success() {
77            return Err(Error::api_error_from_response(response, None).await);
78        }
79        parse_json_or_default::<Webhook>(response).await
80    }
81
82    /// Lists webhooks.
83    ///
84    /// # Errors
85    /// Returns an error when the request fails or the config is invalid.
86    pub async fn list(&self) -> Result<WebhookListResponse> {
87        self.list_with_config(ListWebhooksConfig::default()).await
88    }
89
90    /// Lists webhooks with pagination config.
91    ///
92    /// # Errors
93    /// Returns an error when the request fails or the config is invalid.
94    pub async fn list_with_config(
95        &self,
96        mut config: ListWebhooksConfig,
97    ) -> Result<WebhookListResponse> {
98        ensure_gemini_backend(&self.inner)?;
99        let http_options = config.http_options.take();
100        let url = add_list_query_params(
101            &build_webhooks_url(&self.inner, http_options.as_ref()),
102            &config,
103        )?;
104        let mut request = self.inner.http.get(url);
105        request = apply_http_options(request, http_options.as_ref())?;
106
107        let response = self
108            .inner
109            .send_with_http_options(request, http_options.as_ref())
110            .await?;
111        if !response.status().is_success() {
112            return Err(Error::api_error_from_response(response, None).await);
113        }
114        parse_json_or_default::<WebhookListResponse>(response).await
115    }
116
117    /// Deletes a webhook.
118    ///
119    /// # Errors
120    /// Returns an error when the request fails.
121    pub async fn delete(&self, id: impl AsRef<str>) -> Result<WebhookDeleteResponse> {
122        self.delete_with_config(id, DeleteWebhookConfig::default())
123            .await
124    }
125
126    /// Deletes a webhook with config.
127    ///
128    /// # Errors
129    /// Returns an error when the request fails.
130    pub async fn delete_with_config(
131        &self,
132        id: impl AsRef<str>,
133        mut config: DeleteWebhookConfig,
134    ) -> Result<WebhookDeleteResponse> {
135        ensure_gemini_backend(&self.inner)?;
136        let http_options = config.http_options.take();
137        let name = normalize_webhook_name(id.as_ref());
138        let url = build_webhook_url(&self.inner, &name, http_options.as_ref());
139        let mut request = self.inner.http.delete(url);
140        request = apply_http_options(request, http_options.as_ref())?;
141
142        let response = self
143            .inner
144            .send_with_http_options(request, http_options.as_ref())
145            .await?;
146        if !response.status().is_success() {
147            return Err(Error::api_error_from_response(response, None).await);
148        }
149        parse_json_or_default::<WebhookDeleteResponse>(response).await
150    }
151
152    /// Gets a webhook.
153    ///
154    /// # Errors
155    /// Returns an error when the request fails.
156    pub async fn get(&self, id: impl AsRef<str>) -> Result<Webhook> {
157        self.get_with_config(id, GetWebhookConfig::default()).await
158    }
159
160    /// Gets a webhook with config.
161    ///
162    /// # Errors
163    /// Returns an error when the request fails.
164    pub async fn get_with_config(
165        &self,
166        id: impl AsRef<str>,
167        mut config: GetWebhookConfig,
168    ) -> Result<Webhook> {
169        ensure_gemini_backend(&self.inner)?;
170        let http_options = config.http_options.take();
171        let name = normalize_webhook_name(id.as_ref());
172        let url = build_webhook_url(&self.inner, &name, http_options.as_ref());
173        let mut request = self.inner.http.get(url);
174        request = apply_http_options(request, http_options.as_ref())?;
175
176        let response = self
177            .inner
178            .send_with_http_options(request, http_options.as_ref())
179            .await?;
180        if !response.status().is_success() {
181            return Err(Error::api_error_from_response(response, None).await);
182        }
183        parse_json_or_default::<Webhook>(response).await
184    }
185
186    /// Sends a ping event to a webhook.
187    ///
188    /// # Errors
189    /// Returns an error when the request fails.
190    pub async fn ping(&self, id: impl AsRef<str>) -> Result<WebhookPingResponse> {
191        self.ping_with_config(id, PingWebhookConfig::default())
192            .await
193    }
194
195    /// Sends a ping event to a webhook with config.
196    ///
197    /// # Errors
198    /// Returns an error when the request fails.
199    pub async fn ping_with_config(
200        &self,
201        id: impl AsRef<str>,
202        mut config: PingWebhookConfig,
203    ) -> Result<WebhookPingResponse> {
204        ensure_gemini_backend(&self.inner)?;
205        let http_options = config.http_options.take();
206        let name = normalize_webhook_name(id.as_ref());
207        let url = build_webhook_ping_url(&self.inner, &name, http_options.as_ref());
208        let mut request = self.inner.http.post(url).json(&serde_json::json!({}));
209        request = apply_http_options(request, http_options.as_ref())?;
210
211        let response = self
212            .inner
213            .send_with_http_options(request, http_options.as_ref())
214            .await?;
215        if !response.status().is_success() {
216            return Err(Error::api_error_from_response(response, None).await);
217        }
218        parse_json_or_default::<WebhookPingResponse>(response).await
219    }
220
221    /// Rotates the signing secret for a webhook.
222    ///
223    /// # Errors
224    /// Returns an error when the request fails.
225    pub async fn rotate_signing_secret(
226        &self,
227        id: impl AsRef<str>,
228    ) -> Result<WebhookRotateSigningSecretResponse> {
229        self.rotate_signing_secret_with_config(id, RotateWebhookSigningSecretConfig::default())
230            .await
231    }
232
233    /// Rotates the signing secret for a webhook with config.
234    ///
235    /// # Errors
236    /// Returns an error when the request fails.
237    pub async fn rotate_signing_secret_with_config(
238        &self,
239        id: impl AsRef<str>,
240        mut config: RotateWebhookSigningSecretConfig,
241    ) -> Result<WebhookRotateSigningSecretResponse> {
242        ensure_gemini_backend(&self.inner)?;
243        let http_options = config.http_options.take();
244        let name = normalize_webhook_name(id.as_ref());
245        let url = build_webhook_rotate_url(&self.inner, &name, http_options.as_ref());
246        let mut request = self.inner.http.post(url).json(&config);
247        request = apply_http_options(request, http_options.as_ref())?;
248
249        let response = self
250            .inner
251            .send_with_http_options(request, http_options.as_ref())
252            .await?;
253        if !response.status().is_success() {
254            return Err(Error::api_error_from_response(response, None).await);
255        }
256        parse_json_or_default::<WebhookRotateSigningSecretResponse>(response).await
257    }
258}
259
260fn ensure_gemini_backend(inner: &ClientInner) -> Result<()> {
261    if inner.config.backend != Backend::GeminiApi {
262        return Err(Error::InvalidConfig {
263            message: "Webhooks API is only supported for Gemini API backend".into(),
264        });
265    }
266    Ok(())
267}
268
269fn validate_create_config(config: &CreateWebhookConfig) -> Result<()> {
270    if config.uri.trim().is_empty() {
271        return Err(Error::InvalidConfig {
272            message: "Webhook uri is empty".into(),
273        });
274    }
275    if config.subscribed_events.is_empty() {
276        return Err(Error::InvalidConfig {
277            message: "Webhook subscribed_events is empty".into(),
278        });
279    }
280    Ok(())
281}
282
283fn validate_update_config(config: &UpdateWebhookConfig) -> Result<()> {
284    if config.uri.trim().is_empty() {
285        return Err(Error::InvalidConfig {
286            message: "Webhook uri is empty".into(),
287        });
288    }
289    if config.subscribed_events.is_empty() {
290        return Err(Error::InvalidConfig {
291            message: "Webhook subscribed_events is empty".into(),
292        });
293    }
294    Ok(())
295}
296
297fn normalize_webhook_name(name: &str) -> String {
298    if name.starts_with("webhooks/") {
299        name.to_string()
300    } else {
301        format!("webhooks/{name}")
302    }
303}
304
305fn build_webhooks_url(
306    inner: &ClientInner,
307    http_options: Option<&rust_genai_types::http::HttpOptions>,
308) -> String {
309    let base = http_options
310        .and_then(|opts| opts.base_url.as_deref())
311        .unwrap_or(&inner.api_client.base_url);
312    let version = http_options
313        .and_then(|opts| opts.api_version.as_deref())
314        .unwrap_or(&inner.api_client.api_version);
315    format!("{base}{version}/webhooks")
316}
317
318fn build_webhook_url(
319    inner: &ClientInner,
320    name: &str,
321    http_options: Option<&rust_genai_types::http::HttpOptions>,
322) -> String {
323    let base = http_options
324        .and_then(|opts| opts.base_url.as_deref())
325        .unwrap_or(&inner.api_client.base_url);
326    let version = http_options
327        .and_then(|opts| opts.api_version.as_deref())
328        .unwrap_or(&inner.api_client.api_version);
329    format!("{base}{version}/{name}")
330}
331
332fn build_webhook_ping_url(
333    inner: &ClientInner,
334    name: &str,
335    http_options: Option<&rust_genai_types::http::HttpOptions>,
336) -> String {
337    format!("{}:ping", build_webhook_url(inner, name, http_options))
338}
339
340fn build_webhook_rotate_url(
341    inner: &ClientInner,
342    name: &str,
343    http_options: Option<&rust_genai_types::http::HttpOptions>,
344) -> String {
345    format!(
346        "{}:rotateSigningSecret",
347        build_webhook_url(inner, name, http_options)
348    )
349}
350
351fn add_create_query_params(url: &str, webhook_id: Option<&str>) -> Result<String> {
352    let mut url = reqwest::Url::parse(url).map_err(|err| Error::InvalidConfig {
353        message: err.to_string(),
354    })?;
355    if let Some(webhook_id) = webhook_id.filter(|value| !value.trim().is_empty()) {
356        url.query_pairs_mut().append_pair("webhook_id", webhook_id);
357    }
358    Ok(url.to_string())
359}
360
361fn add_update_query_params(url: &str, update_mask: Option<&str>) -> Result<String> {
362    let mut url = reqwest::Url::parse(url).map_err(|err| Error::InvalidConfig {
363        message: err.to_string(),
364    })?;
365    if let Some(update_mask) = update_mask.filter(|value| !value.trim().is_empty()) {
366        url.query_pairs_mut()
367            .append_pair("update_mask", update_mask);
368    }
369    Ok(url.to_string())
370}
371
372fn add_list_query_params(url: &str, config: &ListWebhooksConfig) -> Result<String> {
373    let mut url = reqwest::Url::parse(url).map_err(|err| Error::InvalidConfig {
374        message: err.to_string(),
375    })?;
376    {
377        let mut pairs = url.query_pairs_mut();
378        if let Some(page_size) = config.page_size {
379            pairs.append_pair("page_size", &page_size.to_string());
380        }
381        if let Some(page_token) = &config.page_token {
382            if !page_token.is_empty() {
383                pairs.append_pair("page_token", page_token);
384            }
385        }
386    }
387    Ok(url.to_string())
388}
389
390fn apply_http_options(
391    mut request: reqwest::RequestBuilder,
392    http_options: Option<&rust_genai_types::http::HttpOptions>,
393) -> Result<reqwest::RequestBuilder> {
394    if let Some(options) = http_options {
395        if let Some(timeout) = options.timeout {
396            request = request.timeout(Duration::from_millis(timeout));
397        }
398        if let Some(headers) = &options.headers {
399            for (key, value) in headers {
400                let name =
401                    HeaderName::from_bytes(key.as_bytes()).map_err(|_| Error::InvalidConfig {
402                        message: format!("Invalid header name: {key}"),
403                    })?;
404                let value = HeaderValue::from_str(value).map_err(|_| Error::InvalidConfig {
405                    message: format!("Invalid header value for {key}"),
406                })?;
407                request = request.header(name, value);
408            }
409        }
410    }
411    Ok(request)
412}
413
414async fn parse_json_or_default<T>(response: reqwest::Response) -> Result<T>
415where
416    T: serde::de::DeserializeOwned + Default,
417{
418    let text = response.text().await.unwrap_or_default();
419    if text.trim().is_empty() {
420        return Ok(T::default());
421    }
422    Ok(serde_json::from_str(&text)?)
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    use std::sync::Arc;
430
431    use reqwest::header::HeaderMap;
432
433    use crate::client::{ApiClient, ClientConfig};
434    use crate::{ClientBuilder, Credentials, HttpOptions, VertexConfig};
435
436    fn test_inner(backend: Backend) -> ClientInner {
437        ClientInner {
438            http: reqwest::Client::new(),
439            config: ClientConfig {
440                api_key: Some("test-key".to_string()),
441                backend,
442                vertex_config: Some(VertexConfig {
443                    project: "proj".to_string(),
444                    location: "loc".to_string(),
445                    credentials: None,
446                }),
447                http_options: HttpOptions::default(),
448                credentials: Credentials::ApiKey("test-key".to_string()),
449                auth_scopes: vec![],
450            },
451            api_client: ApiClient {
452                base_url: "https://example.com/".to_string(),
453                api_version: "v1beta".to_string(),
454            },
455            auth_provider: None,
456        }
457    }
458
459    #[test]
460    fn test_normalize_webhook_name() {
461        assert_eq!(normalize_webhook_name("hook-1"), "webhooks/hook-1");
462        assert_eq!(normalize_webhook_name("webhooks/hook-1"), "webhooks/hook-1");
463    }
464
465    #[test]
466    fn test_webhook_urls_and_query_params() {
467        let inner = test_inner(Backend::GeminiApi);
468        let url = build_webhooks_url(&inner, None);
469        assert_eq!(url, "https://example.com/v1beta/webhooks");
470
471        let url = build_webhook_url(&inner, "webhooks/hook-1", None);
472        assert_eq!(url, "https://example.com/v1beta/webhooks/hook-1");
473
474        let url = build_webhook_ping_url(&inner, "webhooks/hook-1", None);
475        assert_eq!(url, "https://example.com/v1beta/webhooks/hook-1:ping");
476
477        let url = build_webhook_rotate_url(&inner, "webhooks/hook-1", None);
478        assert_eq!(
479            url,
480            "https://example.com/v1beta/webhooks/hook-1:rotateSigningSecret"
481        );
482
483        let url =
484            add_create_query_params(&build_webhooks_url(&inner, None), Some("hook-1")).unwrap();
485        assert!(url.contains("webhook_id=hook-1"));
486
487        let url = add_update_query_params(
488            &build_webhook_url(&inner, "webhooks/hook-1", None),
489            Some("uri,subscribed_events"),
490        )
491        .unwrap();
492        assert!(url.contains("update_mask=uri%2Csubscribed_events"));
493
494        let url = add_list_query_params(
495            &build_webhooks_url(&inner, None),
496            &ListWebhooksConfig {
497                page_size: Some(10),
498                page_token: Some("page-2".to_string()),
499                ..Default::default()
500            },
501        )
502        .unwrap();
503        assert!(url.contains("page_size=10"));
504        assert!(url.contains("page_token=page-2"));
505    }
506
507    #[test]
508    fn test_webhook_validation() {
509        let err = validate_create_config(&CreateWebhookConfig::new("", vec!["x".to_string()]))
510            .unwrap_err();
511        assert!(matches!(err, Error::InvalidConfig { .. }));
512
513        let err =
514            validate_update_config(&UpdateWebhookConfig::new("https://example.com", Vec::new()))
515                .unwrap_err();
516        assert!(matches!(err, Error::InvalidConfig { .. }));
517    }
518
519    #[test]
520    fn test_webhooks_require_gemini_backend() {
521        let client = ClientBuilder::default()
522            .backend(Backend::VertexAi)
523            .vertex_project("proj")
524            .vertex_location("loc")
525            .build()
526            .unwrap();
527        let webhooks = client.webhooks();
528        let err = webhooks.list();
529        let runtime = tokio::runtime::Runtime::new().unwrap();
530        let err = runtime.block_on(err).unwrap_err();
531        assert!(matches!(err, Error::InvalidConfig { .. }));
532    }
533
534    #[test]
535    fn test_apply_http_options_invalid_header_value() {
536        let client = reqwest::Client::new();
537        let request = client.get("https://example.com");
538        let options = rust_genai_types::http::HttpOptions {
539            headers: Some([("x-test".to_string(), "bad\nvalue".to_string())].into()),
540            ..Default::default()
541        };
542        let err = apply_http_options(request, Some(&options)).unwrap_err();
543        assert!(matches!(err, Error::InvalidConfig { .. }));
544    }
545
546    #[test]
547    fn test_parse_json_or_default_empty_body() {
548        let _headers = HeaderMap::new();
549        let inner = Arc::new(test_inner(Backend::GeminiApi));
550        let service = Webhooks::new(inner);
551        let config =
552            CreateWebhookConfig::new("https://example.com", vec!["batch.succeeded".into()]);
553        assert_eq!(service.inner.config.backend, Backend::GeminiApi);
554        assert_eq!(config.uri, "https://example.com");
555    }
556}