rustapi_openapi/v31/
webhooks.rs

1//! Webhook definitions for OpenAPI 3.1
2//!
3//! OpenAPI 3.1 adds support for webhooks at the root level of the specification.
4//! Webhooks define callback URLs that your API can call when events occur.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9use super::schema::JsonSchema2020;
10
11/// Webhook definition for OpenAPI 3.1
12///
13/// A webhook describes an HTTP callback that your API will call when
14/// a specific event occurs.
15#[derive(Debug, Clone, Serialize, Deserialize, Default)]
16pub struct Webhook {
17    /// Summary of the webhook
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub summary: Option<String>,
20
21    /// Detailed description of the webhook
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub description: Option<String>,
24
25    /// HTTP methods for the webhook (typically POST)
26    #[serde(flatten)]
27    pub operations: HashMap<String, WebhookOperation>,
28}
29
30impl Webhook {
31    /// Create a new webhook
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    /// Create a webhook with a summary
37    pub fn with_summary(summary: impl Into<String>) -> Self {
38        Self {
39            summary: Some(summary.into()),
40            ..Default::default()
41        }
42    }
43
44    /// Set the summary
45    pub fn summary(mut self, summary: impl Into<String>) -> Self {
46        self.summary = Some(summary.into());
47        self
48    }
49
50    /// Set the description
51    pub fn description(mut self, description: impl Into<String>) -> Self {
52        self.description = Some(description.into());
53        self
54    }
55
56    /// Add a POST operation
57    pub fn post(mut self, operation: WebhookOperation) -> Self {
58        self.operations.insert("post".to_string(), operation);
59        self
60    }
61
62    /// Add a GET operation
63    pub fn get(mut self, operation: WebhookOperation) -> Self {
64        self.operations.insert("get".to_string(), operation);
65        self
66    }
67
68    /// Add a PUT operation
69    pub fn put(mut self, operation: WebhookOperation) -> Self {
70        self.operations.insert("put".to_string(), operation);
71        self
72    }
73
74    /// Add a DELETE operation
75    pub fn delete(mut self, operation: WebhookOperation) -> Self {
76        self.operations.insert("delete".to_string(), operation);
77        self
78    }
79
80    /// Add an operation with a specific HTTP method
81    pub fn operation(mut self, method: impl Into<String>, op: WebhookOperation) -> Self {
82        self.operations.insert(method.into().to_lowercase(), op);
83        self
84    }
85}
86
87/// Webhook operation (similar to path operation)
88#[derive(Debug, Clone, Serialize, Deserialize, Default)]
89#[serde(rename_all = "camelCase")]
90pub struct WebhookOperation {
91    /// Tags for API documentation
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub tags: Option<Vec<String>>,
94
95    /// Brief summary
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub summary: Option<String>,
98
99    /// Detailed description
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub description: Option<String>,
102
103    /// External documentation
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub external_docs: Option<ExternalDocs>,
106
107    /// Unique operation ID
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub operation_id: Option<String>,
110
111    /// Request body schema
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub request_body: Option<WebhookRequestBody>,
114
115    /// Expected responses from the webhook consumer
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub responses: Option<HashMap<String, WebhookResponse>>,
118
119    /// Security requirements
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub security: Option<Vec<HashMap<String, Vec<String>>>>,
122
123    /// Whether this operation is deprecated
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub deprecated: Option<bool>,
126}
127
128impl WebhookOperation {
129    /// Create a new webhook operation
130    pub fn new() -> Self {
131        Self::default()
132    }
133
134    /// Set the summary
135    pub fn summary(mut self, summary: impl Into<String>) -> Self {
136        self.summary = Some(summary.into());
137        self
138    }
139
140    /// Set the description
141    pub fn description(mut self, description: impl Into<String>) -> Self {
142        self.description = Some(description.into());
143        self
144    }
145
146    /// Set the operation ID
147    pub fn operation_id(mut self, id: impl Into<String>) -> Self {
148        self.operation_id = Some(id.into());
149        self
150    }
151
152    /// Add tags
153    pub fn tags(mut self, tags: Vec<String>) -> Self {
154        self.tags = Some(tags);
155        self
156    }
157
158    /// Set the request body
159    pub fn request_body(mut self, body: WebhookRequestBody) -> Self {
160        self.request_body = Some(body);
161        self
162    }
163
164    /// Add a response
165    pub fn response(mut self, status: impl Into<String>, response: WebhookResponse) -> Self {
166        let responses = self.responses.get_or_insert_with(HashMap::new);
167        responses.insert(status.into(), response);
168        self
169    }
170
171    /// Mark as deprecated
172    pub fn deprecated(mut self) -> Self {
173        self.deprecated = Some(true);
174        self
175    }
176}
177
178/// External documentation link
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct ExternalDocs {
181    /// URL to external documentation
182    pub url: String,
183
184    /// Description
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub description: Option<String>,
187}
188
189impl ExternalDocs {
190    /// Create new external documentation
191    pub fn new(url: impl Into<String>) -> Self {
192        Self {
193            url: url.into(),
194            description: None,
195        }
196    }
197
198    /// Add description
199    pub fn with_description(mut self, description: impl Into<String>) -> Self {
200        self.description = Some(description.into());
201        self
202    }
203}
204
205/// Request body for webhook
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct WebhookRequestBody {
208    /// Description of the request body
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub description: Option<String>,
211
212    /// Whether the body is required
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub required: Option<bool>,
215
216    /// Content by media type
217    pub content: HashMap<String, MediaTypeObject>,
218}
219
220impl WebhookRequestBody {
221    /// Create a new request body with JSON content
222    pub fn json(schema: JsonSchema2020) -> Self {
223        let mut content = HashMap::new();
224        content.insert(
225            "application/json".to_string(),
226            MediaTypeObject {
227                schema: Some(schema),
228                example: None,
229                examples: None,
230            },
231        );
232        Self {
233            description: None,
234            required: Some(true),
235            content,
236        }
237    }
238
239    /// Set description
240    pub fn with_description(mut self, description: impl Into<String>) -> Self {
241        self.description = Some(description.into());
242        self
243    }
244
245    /// Set required
246    pub fn required(mut self, required: bool) -> Self {
247        self.required = Some(required);
248        self
249    }
250}
251
252/// Media type object
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct MediaTypeObject {
255    /// Schema for the content
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub schema: Option<JsonSchema2020>,
258
259    /// Example value
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub example: Option<serde_json::Value>,
262
263    /// Named examples
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub examples: Option<HashMap<String, Example>>,
266}
267
268/// Example object
269#[derive(Debug, Clone, Serialize, Deserialize)]
270#[serde(rename_all = "camelCase")]
271pub struct Example {
272    /// Summary
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub summary: Option<String>,
275
276    /// Description
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub description: Option<String>,
279
280    /// Example value
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub value: Option<serde_json::Value>,
283
284    /// External example URL
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub external_value: Option<String>,
287}
288
289/// Webhook response
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct WebhookResponse {
292    /// Description of the response
293    pub description: String,
294
295    /// Response content
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub content: Option<HashMap<String, MediaTypeObject>>,
298
299    /// Response headers
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub headers: Option<HashMap<String, Header>>,
302}
303
304impl WebhookResponse {
305    /// Create a new response with description
306    pub fn new(description: impl Into<String>) -> Self {
307        Self {
308            description: description.into(),
309            content: None,
310            headers: None,
311        }
312    }
313
314    /// Add JSON content
315    pub fn with_json(mut self, schema: JsonSchema2020) -> Self {
316        let content = self.content.get_or_insert_with(HashMap::new);
317        content.insert(
318            "application/json".to_string(),
319            MediaTypeObject {
320                schema: Some(schema),
321                example: None,
322                examples: None,
323            },
324        );
325        self
326    }
327}
328
329/// Response header
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct Header {
332    /// Description
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub description: Option<String>,
335
336    /// Whether required
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub required: Option<bool>,
339
340    /// Schema
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub schema: Option<JsonSchema2020>,
343
344    /// Deprecated
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub deprecated: Option<bool>,
347}
348
349/// Callback definition
350///
351/// A callback is a set of webhook URLs that may be called based on an operation.
352/// Each callback can contain multiple expressions (URL templates) and operations.
353#[derive(Debug, Clone, Serialize, Deserialize, Default)]
354pub struct Callback {
355    /// URL expressions mapped to path items
356    #[serde(flatten)]
357    pub expressions: HashMap<String, Webhook>,
358}
359
360impl Callback {
361    /// Create a new callback
362    pub fn new() -> Self {
363        Self::default()
364    }
365
366    /// Add an expression with its webhook definition
367    ///
368    /// The expression is a runtime expression that will be evaluated against
369    /// the parent operation's data.
370    pub fn expression(mut self, expr: impl Into<String>, webhook: Webhook) -> Self {
371        self.expressions.insert(expr.into(), webhook);
372        self
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn test_webhook_creation() {
382        let webhook = Webhook::with_summary("Order placed notification")
383            .description("Called when a new order is placed")
384            .post(
385                WebhookOperation::new()
386                    .summary("Notify about new order")
387                    .operation_id("orderPlaced")
388                    .request_body(WebhookRequestBody::json(
389                        JsonSchema2020::object()
390                            .with_property("orderId", JsonSchema2020::string())
391                            .with_property("amount", JsonSchema2020::number())
392                            .with_required("orderId"),
393                    ))
394                    .response(
395                        "200",
396                        WebhookResponse::new("Webhook processed successfully"),
397                    ),
398            );
399
400        assert_eq!(
401            webhook.summary,
402            Some("Order placed notification".to_string())
403        );
404        assert!(webhook.operations.contains_key("post"));
405    }
406
407    #[test]
408    fn test_webhook_serialization() {
409        let webhook = Webhook::new().summary("Test webhook").post(
410            WebhookOperation::new()
411                .operation_id("test")
412                .response("200", WebhookResponse::new("OK")),
413        );
414
415        let json = serde_json::to_value(&webhook).unwrap();
416        assert!(json.get("summary").is_some());
417        assert!(json.get("post").is_some());
418    }
419
420    #[test]
421    fn test_callback_creation() {
422        let callback = Callback::new().expression(
423            "{$request.body#/callbackUrl}",
424            Webhook::new().post(
425                WebhookOperation::new()
426                    .summary("Callback notification")
427                    .response("200", WebhookResponse::new("Callback received")),
428            ),
429        );
430
431        assert!(callback
432            .expressions
433            .contains_key("{$request.body#/callbackUrl}"));
434    }
435}