mockforge_core/
collection_export.rs

1/// API Collection export functionality
2///
3/// Generates Postman, Insomnia, and Hoppscotch collections from OpenAPI/GraphQL schemas
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7/// Supported collection formats
8#[derive(Debug, Clone, Serialize, Deserialize)]
9#[serde(rename_all = "lowercase")]
10pub enum CollectionFormat {
11    Postman,
12    Insomnia,
13    Hoppscotch,
14}
15
16/// Postman collection v2.1 format
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct PostmanCollection {
19    pub info: PostmanInfo,
20    pub item: Vec<PostmanItem>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub variable: Option<Vec<PostmanVariable>>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct PostmanInfo {
27    pub name: String,
28    pub description: String,
29    pub schema: String,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub version: Option<String>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct PostmanItem {
36    pub name: String,
37    pub request: PostmanRequest,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub response: Option<Vec<Value>>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct PostmanRequest {
44    pub method: String,
45    pub header: Vec<PostmanHeader>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub body: Option<PostmanBody>,
48    pub url: PostmanUrl,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub description: Option<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct PostmanHeader {
55    pub key: String,
56    pub value: String,
57    #[serde(rename = "type")]
58    pub header_type: String,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct PostmanBody {
63    pub mode: String,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub raw: Option<String>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub options: Option<Value>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct PostmanUrl {
72    pub raw: String,
73    pub host: Vec<String>,
74    pub path: Vec<String>,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub query: Option<Vec<PostmanQueryParam>>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct PostmanQueryParam {
81    pub key: String,
82    pub value: String,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct PostmanVariable {
87    pub key: String,
88    pub value: String,
89    #[serde(rename = "type")]
90    pub var_type: String,
91}
92
93/// Insomnia collection format
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct InsomniaCollection {
96    pub _type: String,
97    pub __export_format: u8,
98    pub __export_date: String,
99    pub __export_source: String,
100    pub resources: Vec<InsomniaResource>,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct InsomniaResource {
105    pub _id: String,
106    pub _type: String,
107    pub name: String,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub method: Option<String>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub url: Option<String>,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub body: Option<Value>,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub headers: Option<Vec<InsomniaHeader>>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct InsomniaHeader {
120    pub name: String,
121    pub value: String,
122}
123
124/// Hoppscotch collection format
125#[derive(Debug, Clone, Serialize, Deserialize)]
126#[serde(rename_all = "camelCase")]
127pub struct HoppscotchCollection {
128    pub name: String,
129    pub requests: Vec<HoppscotchRequest>,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub folders: Option<Vec<HoppscotchFolder>>,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
135#[serde(rename_all = "camelCase")]
136pub struct HoppscotchRequest {
137    pub name: String,
138    pub method: String,
139    pub endpoint: String,
140    pub headers: Vec<HoppscotchHeader>,
141    pub params: Vec<HoppscotchParam>,
142    pub body: HoppscotchBody,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
146#[serde(rename_all = "camelCase")]
147pub struct HoppscotchHeader {
148    pub key: String,
149    pub value: String,
150    pub active: bool,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
154#[serde(rename_all = "camelCase")]
155pub struct HoppscotchParam {
156    pub key: String,
157    pub value: String,
158    pub active: bool,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162#[serde(rename_all = "camelCase")]
163pub struct HoppscotchBody {
164    pub content_type: String,
165    pub body: String,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169#[serde(rename_all = "camelCase")]
170pub struct HoppscotchFolder {
171    pub name: String,
172    pub requests: Vec<HoppscotchRequest>,
173}
174
175/// Collection exporter
176pub struct CollectionExporter {
177    base_url: String,
178}
179
180impl CollectionExporter {
181    pub fn new(base_url: String) -> Self {
182        Self { base_url }
183    }
184
185    /// Generate Postman collection from OpenAPI spec
186    pub fn to_postman(&self, spec: &crate::openapi::OpenApiSpec) -> PostmanCollection {
187        let mut items = Vec::new();
188
189        for (path, path_item_ref) in &spec.spec.paths.paths {
190            // Unwrap ReferenceOr
191            if let openapiv3::ReferenceOr::Item(path_item) = path_item_ref {
192                let operations = vec![
193                    ("GET", path_item.get.as_ref()),
194                    ("POST", path_item.post.as_ref()),
195                    ("PUT", path_item.put.as_ref()),
196                    ("DELETE", path_item.delete.as_ref()),
197                    ("PATCH", path_item.patch.as_ref()),
198                    ("HEAD", path_item.head.as_ref()),
199                    ("OPTIONS", path_item.options.as_ref()),
200                ];
201
202                for (method, op_opt) in operations {
203                    if let Some(op) = op_opt {
204                        let name = op
205                            .operation_id
206                            .clone()
207                            .or_else(|| op.summary.clone())
208                            .unwrap_or_else(|| format!("{} {}", method, path));
209
210                        let request = PostmanRequest {
211                            method: method.to_string(),
212                            header: vec![PostmanHeader {
213                                key: "Content-Type".to_string(),
214                                value: "application/json".to_string(),
215                                header_type: "text".to_string(),
216                            }],
217                            body: if matches!(method, "POST" | "PUT" | "PATCH") {
218                                Some(PostmanBody {
219                                    mode: "raw".to_string(),
220                                    raw: Some("{}".to_string()),
221                                    options: Some(serde_json::json!({
222                                        "raw": {
223                                            "language": "json"
224                                        }
225                                    })),
226                                })
227                            } else {
228                                None
229                            },
230                            url: PostmanUrl {
231                                raw: format!("{}{}", self.base_url, path),
232                                host: vec![self.base_url.clone()],
233                                path: path
234                                    .split('/')
235                                    .filter(|s| !s.is_empty())
236                                    .map(String::from)
237                                    .collect(),
238                                query: None,
239                            },
240                            description: op.description.clone(),
241                        };
242
243                        items.push(PostmanItem {
244                            name,
245                            request,
246                            response: None,
247                        });
248                    }
249                }
250            }
251        }
252
253        PostmanCollection {
254            info: PostmanInfo {
255                name: spec.spec.info.title.clone(),
256                description: spec.spec.info.description.clone().unwrap_or_default(),
257                schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
258                    .to_string(),
259                version: Some(spec.spec.info.version.clone()),
260            },
261            item: items,
262            variable: Some(vec![PostmanVariable {
263                key: "baseUrl".to_string(),
264                value: self.base_url.clone(),
265                var_type: "string".to_string(),
266            }]),
267        }
268    }
269
270    /// Generate Insomnia collection
271    pub fn to_insomnia(&self, spec: &crate::openapi::OpenApiSpec) -> InsomniaCollection {
272        let mut resources = Vec::new();
273
274        // Add workspace resource
275        resources.push(InsomniaResource {
276            _id: "wrk_1".to_string(),
277            _type: "workspace".to_string(),
278            name: spec.spec.info.title.clone(),
279            method: None,
280            url: None,
281            body: None,
282            headers: None,
283        });
284
285        // Add request resources
286        let mut id_counter = 1;
287        for (path, path_item_ref) in &spec.spec.paths.paths {
288            if let openapiv3::ReferenceOr::Item(path_item) = path_item_ref {
289                let operations = vec![
290                    ("GET", path_item.get.as_ref()),
291                    ("POST", path_item.post.as_ref()),
292                    ("PUT", path_item.put.as_ref()),
293                    ("DELETE", path_item.delete.as_ref()),
294                    ("PATCH", path_item.patch.as_ref()),
295                    ("HEAD", path_item.head.as_ref()),
296                    ("OPTIONS", path_item.options.as_ref()),
297                ];
298
299                for (method, op_opt) in operations {
300                    if let Some(op) = op_opt {
301                        id_counter += 1;
302
303                        let name = op
304                            .operation_id
305                            .clone()
306                            .or_else(|| op.summary.clone())
307                            .unwrap_or_else(|| format!("{} {}", method, path));
308
309                        resources.push(InsomniaResource {
310                            _id: format!("req_{}", id_counter),
311                            _type: "request".to_string(),
312                            name,
313                            method: Some(method.to_string()),
314                            url: Some(format!("{}{}", self.base_url, path)),
315                            body: if matches!(method, "POST" | "PUT" | "PATCH") {
316                                Some(serde_json::json!({
317                                    "mimeType": "application/json",
318                                    "text": "{}"
319                                }))
320                            } else {
321                                None
322                            },
323                            headers: Some(vec![InsomniaHeader {
324                                name: "Content-Type".to_string(),
325                                value: "application/json".to_string(),
326                            }]),
327                        });
328                    }
329                }
330            }
331        }
332
333        InsomniaCollection {
334            _type: "export".to_string(),
335            __export_format: 4,
336            __export_date: chrono::Utc::now().to_rfc3339(),
337            __export_source: "mockforge".to_string(),
338            resources,
339        }
340    }
341
342    /// Generate Hoppscotch collection
343    pub fn to_hoppscotch(&self, spec: &crate::openapi::OpenApiSpec) -> HoppscotchCollection {
344        let mut requests = Vec::new();
345
346        for (path, path_item_ref) in &spec.spec.paths.paths {
347            if let openapiv3::ReferenceOr::Item(path_item) = path_item_ref {
348                let operations = vec![
349                    ("GET", path_item.get.as_ref()),
350                    ("POST", path_item.post.as_ref()),
351                    ("PUT", path_item.put.as_ref()),
352                    ("DELETE", path_item.delete.as_ref()),
353                    ("PATCH", path_item.patch.as_ref()),
354                    ("HEAD", path_item.head.as_ref()),
355                    ("OPTIONS", path_item.options.as_ref()),
356                ];
357
358                for (method, op_opt) in operations {
359                    if let Some(op) = op_opt {
360                        let name = op
361                            .operation_id
362                            .clone()
363                            .or_else(|| op.summary.clone())
364                            .unwrap_or_else(|| format!("{} {}", method, path));
365
366                        requests.push(HoppscotchRequest {
367                            name,
368                            method: method.to_string(),
369                            endpoint: format!("{}{}", self.base_url, path),
370                            headers: vec![HoppscotchHeader {
371                                key: "Content-Type".to_string(),
372                                value: "application/json".to_string(),
373                                active: true,
374                            }],
375                            params: vec![],
376                            body: HoppscotchBody {
377                                content_type: "application/json".to_string(),
378                                body: "{}".to_string(),
379                            },
380                        });
381                    }
382                }
383            }
384        }
385
386        HoppscotchCollection {
387            name: spec.spec.info.title.clone(),
388            requests,
389            folders: None,
390        }
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    #[test]
399    fn test_postman_collection_structure() {
400        let collection = PostmanCollection {
401            info: PostmanInfo {
402                name: "Test API".to_string(),
403                description: "Test description".to_string(),
404                schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
405                    .to_string(),
406                version: Some("1.0.0".to_string()),
407            },
408            item: vec![],
409            variable: None,
410        };
411
412        assert_eq!(collection.info.name, "Test API");
413    }
414
415    #[test]
416    fn test_insomnia_collection_structure() {
417        let collection = InsomniaCollection {
418            _type: "export".to_string(),
419            __export_format: 4,
420            __export_date: "2024-01-01T00:00:00Z".to_string(),
421            __export_source: "mockforge".to_string(),
422            resources: vec![],
423        };
424
425        assert_eq!(collection._type, "export");
426        assert_eq!(collection.__export_format, 4);
427    }
428
429    #[test]
430    fn test_hoppscotch_collection_structure() {
431        let collection = HoppscotchCollection {
432            name: "Test API".to_string(),
433            requests: vec![],
434            folders: None,
435        };
436
437        assert_eq!(collection.name, "Test API");
438    }
439}