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