Skip to main content

shopify_sdk/rest/resources/v2025_10/
webhook.rs

1//! Webhook resource implementation.
2//!
3//! This module provides the Webhook resource, which represents a webhook subscription
4//! in a Shopify store. Webhooks allow apps to receive notifications when specific
5//! events occur in the store.
6//!
7//! # Example
8//!
9//! ```rust,ignore
10//! use shopify_sdk::rest::{RestResource, ResourceResponse};
11//! use shopify_sdk::rest::resources::v2025_10::{Webhook, WebhookListParams};
12//! use shopify_sdk::rest::resources::v2025_10::common::{WebhookTopic, WebhookFormat};
13//!
14//! // Find a single webhook
15//! let webhook = Webhook::find(&client, 123, None).await?;
16//! println!("Webhook: {} -> {}", webhook.topic.map(|t| format!("{:?}", t)).unwrap_or_default(), webhook.address.as_deref().unwrap_or(""));
17//!
18//! // List webhooks with topic filter
19//! let params = WebhookListParams {
20//!     topic: Some("orders/create".to_string()),
21//!     limit: Some(50),
22//!     ..Default::default()
23//! };
24//! let webhooks = Webhook::all(&client, Some(params)).await?;
25//!
26//! // Create a new webhook
27//! let mut webhook = Webhook {
28//!     topic: Some(WebhookTopic::OrdersCreate),
29//!     address: Some("https://example.com/webhooks/orders".to_string()),
30//!     format: Some(WebhookFormat::Json),
31//!     ..Default::default()
32//! };
33//! let saved = webhook.save(&client).await?;
34//!
35//! // Count webhooks
36//! let count = Webhook::count(&client, None).await?;
37//! println!("Total webhooks: {}", count);
38//! ```
39
40use chrono::{DateTime, Utc};
41use serde::{Deserialize, Serialize};
42
43use crate::rest::{ResourceOperation, ResourcePath, RestResource};
44use crate::HttpMethod;
45
46use super::common::{WebhookFormat, WebhookTopic};
47
48/// A webhook subscription in a Shopify store.
49///
50/// Webhooks allow apps to receive HTTP POST notifications when specific events
51/// occur in a Shopify store. When the subscribed event occurs, Shopify sends
52/// a POST request to the webhook's address URL with details about the event.
53///
54/// # Fields
55///
56/// ## Read-Only Fields
57/// - `id` - The unique identifier of the webhook
58/// - `created_at` - When the webhook was created
59/// - `updated_at` - When the webhook was last updated
60/// - `admin_graphql_api_id` - The GraphQL API ID
61///
62/// ## Writable Fields
63/// - `address` - The URL where webhook payloads will be sent
64/// - `topic` - The event that triggers the webhook (e.g., orders/create)
65/// - `format` - The format of the webhook payload (json or xml)
66/// - `api_version` - The API version used to serialize the webhook payload
67/// - `fields` - Specific fields to include in the webhook payload
68/// - `metafield_namespaces` - Metafield namespaces to include in the payload
69#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
70pub struct Webhook {
71    /// The unique identifier of the webhook.
72    /// Read-only field.
73    #[serde(skip_serializing)]
74    pub id: Option<u64>,
75
76    /// The URL where webhook payloads will be sent.
77    /// Must be a valid HTTPS URL.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub address: Option<String>,
80
81    /// The event that triggers the webhook.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub topic: Option<WebhookTopic>,
84
85    /// The format of the webhook payload.
86    /// Defaults to JSON if not specified.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub format: Option<WebhookFormat>,
89
90    /// The API version used to serialize the webhook payload.
91    /// If not specified, uses the API version of the request that created the webhook.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub api_version: Option<String>,
94
95    /// Specific fields to include in the webhook payload.
96    /// If specified, only these fields will be included.
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub fields: Option<Vec<String>>,
99
100    /// Metafield namespaces to include in the webhook payload.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub metafield_namespaces: Option<Vec<String>>,
103
104    /// When the webhook was created.
105    /// Read-only field.
106    #[serde(skip_serializing)]
107    pub created_at: Option<DateTime<Utc>>,
108
109    /// When the webhook was last updated.
110    /// Read-only field.
111    #[serde(skip_serializing)]
112    pub updated_at: Option<DateTime<Utc>>,
113
114    /// The admin GraphQL API ID for this webhook.
115    /// Read-only field.
116    #[serde(skip_serializing)]
117    pub admin_graphql_api_id: Option<String>,
118}
119
120impl RestResource for Webhook {
121    type Id = u64;
122    type FindParams = WebhookFindParams;
123    type AllParams = WebhookListParams;
124    type CountParams = WebhookCountParams;
125
126    const NAME: &'static str = "Webhook";
127    const PLURAL: &'static str = "webhooks";
128
129    const PATHS: &'static [ResourcePath] = &[
130        ResourcePath::new(
131            HttpMethod::Get,
132            ResourceOperation::Find,
133            &["id"],
134            "webhooks/{id}",
135        ),
136        ResourcePath::new(HttpMethod::Get, ResourceOperation::All, &[], "webhooks"),
137        ResourcePath::new(
138            HttpMethod::Get,
139            ResourceOperation::Count,
140            &[],
141            "webhooks/count",
142        ),
143        ResourcePath::new(HttpMethod::Post, ResourceOperation::Create, &[], "webhooks"),
144        ResourcePath::new(
145            HttpMethod::Put,
146            ResourceOperation::Update,
147            &["id"],
148            "webhooks/{id}",
149        ),
150        ResourcePath::new(
151            HttpMethod::Delete,
152            ResourceOperation::Delete,
153            &["id"],
154            "webhooks/{id}",
155        ),
156    ];
157
158    fn get_id(&self) -> Option<Self::Id> {
159        self.id
160    }
161}
162
163/// Parameters for finding a single webhook.
164#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
165pub struct WebhookFindParams {
166    /// Comma-separated list of fields to include in the response.
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub fields: Option<String>,
169}
170
171/// Parameters for listing webhooks.
172#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
173pub struct WebhookListParams {
174    /// Filter webhooks by address.
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub address: Option<String>,
177
178    /// Filter webhooks by topic.
179    /// Can be a `WebhookTopic` value serialized as string (e.g., "orders/create").
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub topic: Option<String>,
182
183    /// Show webhooks created after this date.
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub created_at_min: Option<DateTime<Utc>>,
186
187    /// Show webhooks created before this date.
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub created_at_max: Option<DateTime<Utc>>,
190
191    /// Show webhooks updated after this date.
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub updated_at_min: Option<DateTime<Utc>>,
194
195    /// Show webhooks updated before this date.
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub updated_at_max: Option<DateTime<Utc>>,
198
199    /// Maximum number of results to return (default: 50, max: 250).
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub limit: Option<u32>,
202
203    /// Return webhooks after this ID.
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub since_id: Option<u64>,
206
207    /// Cursor for pagination.
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub page_info: Option<String>,
210
211    /// Comma-separated list of fields to include in the response.
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub fields: Option<String>,
214}
215
216/// Parameters for counting webhooks.
217#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
218pub struct WebhookCountParams {
219    /// Filter webhooks by address.
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub address: Option<String>,
222
223    /// Filter webhooks by topic.
224    /// Can be a `WebhookTopic` value serialized as string (e.g., "orders/create").
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub topic: Option<String>,
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use crate::rest::{get_path, ResourceOperation};
233
234    #[test]
235    fn test_webhook_struct_serialization() {
236        let webhook = Webhook {
237            id: Some(12345),
238            address: Some("https://example.com/webhooks".to_string()),
239            topic: Some(WebhookTopic::OrdersCreate),
240            format: Some(WebhookFormat::Json),
241            api_version: Some("2025-10".to_string()),
242            fields: Some(vec!["id".to_string(), "email".to_string()]),
243            metafield_namespaces: Some(vec!["custom".to_string()]),
244            created_at: Some(
245                DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
246                    .unwrap()
247                    .with_timezone(&Utc),
248            ),
249            updated_at: Some(
250                DateTime::parse_from_rfc3339("2024-06-20T15:45:00Z")
251                    .unwrap()
252                    .with_timezone(&Utc),
253            ),
254            admin_graphql_api_id: Some("gid://shopify/WebhookSubscription/12345".to_string()),
255        };
256
257        let json = serde_json::to_string(&webhook).unwrap();
258        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
259
260        // Writable fields should be present
261        assert_eq!(parsed["address"], "https://example.com/webhooks");
262        assert_eq!(parsed["topic"], "orders/create");
263        assert_eq!(parsed["format"], "json");
264        assert_eq!(parsed["api_version"], "2025-10");
265        assert_eq!(parsed["fields"], serde_json::json!(["id", "email"]));
266        assert_eq!(
267            parsed["metafield_namespaces"],
268            serde_json::json!(["custom"])
269        );
270
271        // Read-only fields should be omitted
272        assert!(parsed.get("id").is_none());
273        assert!(parsed.get("created_at").is_none());
274        assert!(parsed.get("updated_at").is_none());
275        assert!(parsed.get("admin_graphql_api_id").is_none());
276    }
277
278    #[test]
279    fn test_webhook_deserialization_from_api_response() {
280        let json = r#"{
281            "id": 4759306,
282            "address": "https://example.com/webhooks/orders",
283            "topic": "orders/create",
284            "created_at": "2024-01-15T10:30:00Z",
285            "updated_at": "2024-06-20T15:45:00Z",
286            "format": "json",
287            "fields": ["id", "email", "total_price"],
288            "metafield_namespaces": ["custom", "global"],
289            "api_version": "2025-10",
290            "admin_graphql_api_id": "gid://shopify/WebhookSubscription/4759306"
291        }"#;
292
293        let webhook: Webhook = serde_json::from_str(json).unwrap();
294
295        assert_eq!(webhook.id, Some(4759306));
296        assert_eq!(
297            webhook.address,
298            Some("https://example.com/webhooks/orders".to_string())
299        );
300        assert_eq!(webhook.topic, Some(WebhookTopic::OrdersCreate));
301        assert_eq!(webhook.format, Some(WebhookFormat::Json));
302        assert_eq!(webhook.api_version, Some("2025-10".to_string()));
303        assert_eq!(
304            webhook.fields,
305            Some(vec![
306                "id".to_string(),
307                "email".to_string(),
308                "total_price".to_string()
309            ])
310        );
311        assert_eq!(
312            webhook.metafield_namespaces,
313            Some(vec!["custom".to_string(), "global".to_string()])
314        );
315        assert!(webhook.created_at.is_some());
316        assert!(webhook.updated_at.is_some());
317        assert_eq!(
318            webhook.admin_graphql_api_id,
319            Some("gid://shopify/WebhookSubscription/4759306".to_string())
320        );
321    }
322
323    #[test]
324    fn test_webhook_topic_enum_in_struct() {
325        // Test all webhook topics can be serialized in a Webhook struct
326        let topics = vec![
327            (WebhookTopic::OrdersCreate, "orders/create"),
328            (WebhookTopic::OrdersUpdated, "orders/updated"),
329            (WebhookTopic::OrdersPaid, "orders/paid"),
330            (WebhookTopic::ProductsCreate, "products/create"),
331            (WebhookTopic::ProductsUpdate, "products/update"),
332            (WebhookTopic::CustomersCreate, "customers/create"),
333            (WebhookTopic::AppUninstalled, "app/uninstalled"),
334        ];
335
336        for (topic, expected_str) in topics {
337            let webhook = Webhook {
338                topic: Some(topic),
339                address: Some("https://example.com".to_string()),
340                ..Default::default()
341            };
342
343            let json = serde_json::to_value(&webhook).unwrap();
344            assert_eq!(json["topic"], expected_str);
345        }
346    }
347
348    #[test]
349    fn test_webhook_format_enum_handling() {
350        // Test JSON format
351        let webhook_json = Webhook {
352            format: Some(WebhookFormat::Json),
353            address: Some("https://example.com".to_string()),
354            ..Default::default()
355        };
356
357        let json = serde_json::to_value(&webhook_json).unwrap();
358        assert_eq!(json["format"], "json");
359
360        // Test XML format
361        let webhook_xml = Webhook {
362            format: Some(WebhookFormat::Xml),
363            address: Some("https://example.com".to_string()),
364            ..Default::default()
365        };
366
367        let json = serde_json::to_value(&webhook_xml).unwrap();
368        assert_eq!(json["format"], "xml");
369
370        // Test default format
371        assert_eq!(WebhookFormat::default(), WebhookFormat::Json);
372    }
373
374    #[test]
375    fn test_webhook_list_params_with_topic_filter() {
376        let params = WebhookListParams {
377            topic: Some("orders/create".to_string()),
378            address: Some("https://example.com".to_string()),
379            limit: Some(50),
380            since_id: Some(100),
381            ..Default::default()
382        };
383
384        let json = serde_json::to_value(&params).unwrap();
385
386        assert_eq!(json["topic"], "orders/create");
387        assert_eq!(json["address"], "https://example.com");
388        assert_eq!(json["limit"], 50);
389        assert_eq!(json["since_id"], 100);
390
391        // Fields not set should be omitted
392        assert!(json.get("created_at_min").is_none());
393        assert!(json.get("page_info").is_none());
394    }
395
396    #[test]
397    fn test_webhook_count_params() {
398        let params = WebhookCountParams {
399            topic: Some("orders/create".to_string()),
400            address: Some("https://example.com".to_string()),
401        };
402
403        let json = serde_json::to_value(&params).unwrap();
404
405        assert_eq!(json["topic"], "orders/create");
406        assert_eq!(json["address"], "https://example.com");
407
408        // Test empty params
409        let empty_params = WebhookCountParams::default();
410        let empty_json = serde_json::to_value(&empty_params).unwrap();
411        assert_eq!(empty_json, serde_json::json!({}));
412    }
413
414    #[test]
415    fn test_webhook_path_constants_are_correct() {
416        // Test Find path
417        let find_path = get_path(Webhook::PATHS, ResourceOperation::Find, &["id"]);
418        assert!(find_path.is_some());
419        assert_eq!(find_path.unwrap().template, "webhooks/{id}");
420        assert_eq!(find_path.unwrap().http_method, HttpMethod::Get);
421
422        // Test All path
423        let all_path = get_path(Webhook::PATHS, ResourceOperation::All, &[]);
424        assert!(all_path.is_some());
425        assert_eq!(all_path.unwrap().template, "webhooks");
426        assert_eq!(all_path.unwrap().http_method, HttpMethod::Get);
427
428        // Test Count path
429        let count_path = get_path(Webhook::PATHS, ResourceOperation::Count, &[]);
430        assert!(count_path.is_some());
431        assert_eq!(count_path.unwrap().template, "webhooks/count");
432        assert_eq!(count_path.unwrap().http_method, HttpMethod::Get);
433
434        // Test Create path
435        let create_path = get_path(Webhook::PATHS, ResourceOperation::Create, &[]);
436        assert!(create_path.is_some());
437        assert_eq!(create_path.unwrap().template, "webhooks");
438        assert_eq!(create_path.unwrap().http_method, HttpMethod::Post);
439
440        // Test Update path
441        let update_path = get_path(Webhook::PATHS, ResourceOperation::Update, &["id"]);
442        assert!(update_path.is_some());
443        assert_eq!(update_path.unwrap().template, "webhooks/{id}");
444        assert_eq!(update_path.unwrap().http_method, HttpMethod::Put);
445
446        // Test Delete path
447        let delete_path = get_path(Webhook::PATHS, ResourceOperation::Delete, &["id"]);
448        assert!(delete_path.is_some());
449        assert_eq!(delete_path.unwrap().template, "webhooks/{id}");
450        assert_eq!(delete_path.unwrap().http_method, HttpMethod::Delete);
451
452        // Verify constants
453        assert_eq!(Webhook::NAME, "Webhook");
454        assert_eq!(Webhook::PLURAL, "webhooks");
455    }
456
457    #[test]
458    fn test_webhook_get_id_returns_correct_value() {
459        // Webhook with ID
460        let webhook_with_id = Webhook {
461            id: Some(123456789),
462            address: Some("https://example.com".to_string()),
463            ..Default::default()
464        };
465        assert_eq!(webhook_with_id.get_id(), Some(123456789));
466
467        // Webhook without ID (new webhook)
468        let webhook_without_id = Webhook {
469            id: None,
470            address: Some("https://example.com".to_string()),
471            ..Default::default()
472        };
473        assert_eq!(webhook_without_id.get_id(), None);
474    }
475}