1use 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 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 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 pub async fn list(&self) -> Result<WebhookListResponse> {
87 self.list_with_config(ListWebhooksConfig::default()).await
88 }
89
90 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 pub async fn delete(&self, id: impl AsRef<str>) -> Result<WebhookDeleteResponse> {
122 self.delete_with_config(id, DeleteWebhookConfig::default())
123 .await
124 }
125
126 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 pub async fn get(&self, id: impl AsRef<str>) -> Result<Webhook> {
157 self.get_with_config(id, GetWebhookConfig::default()).await
158 }
159
160 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 pub async fn ping(&self, id: impl AsRef<str>) -> Result<WebhookPingResponse> {
191 self.ping_with_config(id, PingWebhookConfig::default())
192 .await
193 }
194
195 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 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 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}