Skip to main content

mockforge_core/openapi/
swagger_convert.rs

1//! Swagger 2.0 to OpenAPI 3.0 conversion
2//!
3//! This module provides functionality to convert Swagger 2.0 specifications
4//! to OpenAPI 3.0 format, enabling tools that only support OpenAPI 3.0 to
5//! work with legacy Swagger 2.0 specs.
6
7use serde_json::{json, Map, Value};
8
9/// Convert a Swagger 2.0 specification to OpenAPI 3.0 format
10///
11/// This performs the following conversions:
12/// - `swagger: "2.0"` → `openapi: "3.0.3"`
13/// - `host` + `basePath` + `schemes` → `servers`
14/// - `consumes`/`produces` → per-operation `requestBody`/`responses` content types
15/// - Parameter `type`/`format` → `schema` object
16/// - `definitions` → `components.schemas`
17/// - `securityDefinitions` → `components.securitySchemes`
18pub fn convert_swagger_to_openapi3(swagger: &Value) -> Result<Value, String> {
19    // Verify this is a Swagger 2.0 spec
20    if swagger.get("swagger").and_then(|v| v.as_str()) != Some("2.0") {
21        return Err("Not a Swagger 2.0 specification".to_string());
22    }
23
24    let mut openapi = Map::new();
25
26    // Set OpenAPI version
27    openapi.insert("openapi".to_string(), json!("3.0.3"));
28
29    // Copy info section (mostly compatible)
30    if let Some(info) = swagger.get("info") {
31        openapi.insert("info".to_string(), info.clone());
32    }
33
34    // Convert servers from host/basePath/schemes
35    let servers = convert_servers(swagger);
36    if !servers.is_empty() {
37        openapi.insert("servers".to_string(), json!(servers));
38    }
39
40    // Copy tags
41    if let Some(tags) = swagger.get("tags") {
42        openapi.insert("tags".to_string(), tags.clone());
43    }
44
45    // Convert paths
46    if let Some(paths) = swagger.get("paths") {
47        let global_consumes =
48            swagger.get("consumes").and_then(|v| v.as_array()).cloned().unwrap_or_default();
49        let global_produces =
50            swagger.get("produces").and_then(|v| v.as_array()).cloned().unwrap_or_default();
51
52        let converted_paths = convert_paths(paths, &global_consumes, &global_produces);
53        openapi.insert("paths".to_string(), converted_paths);
54    }
55
56    // Build components section
57    let mut components = Map::new();
58
59    // Convert definitions to components/schemas
60    if let Some(definitions) = swagger.get("definitions") {
61        components.insert("schemas".to_string(), definitions.clone());
62    }
63
64    // Convert securityDefinitions to components/securitySchemes
65    if let Some(security_defs) = swagger.get("securityDefinitions") {
66        let converted = convert_security_definitions(security_defs);
67        components.insert("securitySchemes".to_string(), converted);
68    }
69
70    if !components.is_empty() {
71        openapi.insert("components".to_string(), json!(components));
72    }
73
74    // Copy global security
75    if let Some(security) = swagger.get("security") {
76        openapi.insert("security".to_string(), security.clone());
77    }
78
79    // Copy externalDocs
80    if let Some(external_docs) = swagger.get("externalDocs") {
81        openapi.insert("externalDocs".to_string(), external_docs.clone());
82    }
83
84    Ok(Value::Object(openapi))
85}
86
87/// Convert Swagger 2.0 host/basePath/schemes to OpenAPI 3.0 servers
88fn convert_servers(swagger: &Value) -> Vec<Value> {
89    let host = swagger.get("host").and_then(|v| v.as_str());
90    let base_path = swagger.get("basePath").and_then(|v| v.as_str()).unwrap_or("");
91    let schemes = swagger
92        .get("schemes")
93        .and_then(|v| v.as_array())
94        .cloned()
95        .unwrap_or_else(|| vec![json!("https")]);
96
97    if let Some(host) = host {
98        schemes
99            .iter()
100            .filter_map(|scheme| {
101                scheme.as_str().map(|s| {
102                    json!({
103                        "url": format!("{}://{}{}", s, host, base_path)
104                    })
105                })
106            })
107            .collect()
108    } else {
109        // No host specified, use relative URL
110        vec![json!({
111            "url": base_path
112        })]
113    }
114}
115
116/// Convert Swagger 2.0 paths to OpenAPI 3.0 format
117fn convert_paths(paths: &Value, global_consumes: &[Value], global_produces: &[Value]) -> Value {
118    let Some(paths_obj) = paths.as_object() else {
119        return paths.clone();
120    };
121
122    let mut converted = Map::new();
123
124    for (path, path_item) in paths_obj {
125        if let Some(path_item_obj) = path_item.as_object() {
126            let converted_path_item =
127                convert_path_item(path_item_obj, global_consumes, global_produces);
128            converted.insert(path.clone(), Value::Object(converted_path_item));
129        }
130    }
131
132    Value::Object(converted)
133}
134
135/// Convert a single path item
136fn convert_path_item(
137    path_item: &Map<String, Value>,
138    global_consumes: &[Value],
139    global_produces: &[Value],
140) -> Map<String, Value> {
141    let mut converted = Map::new();
142
143    for (key, value) in path_item {
144        match key.as_str() {
145            "get" | "post" | "put" | "delete" | "patch" | "head" | "options" => {
146                if let Some(op) = value.as_object() {
147                    let converted_op = convert_operation(op, global_consumes, global_produces);
148                    converted.insert(key.clone(), Value::Object(converted_op));
149                }
150            }
151            "parameters" => {
152                // Path-level parameters
153                if let Some(params) = value.as_array() {
154                    let converted_params: Vec<Value> =
155                        params.iter().map(convert_parameter).collect();
156                    converted.insert(key.clone(), json!(converted_params));
157                }
158            }
159            "$ref" => {
160                // Convert reference
161                if let Some(ref_str) = value.as_str() {
162                    converted.insert(key.clone(), json!(convert_ref(ref_str)));
163                }
164            }
165            _ => {
166                // Copy other fields as-is
167                converted.insert(key.clone(), value.clone());
168            }
169        }
170    }
171
172    converted
173}
174
175/// Convert a single operation
176fn convert_operation(
177    operation: &Map<String, Value>,
178    global_consumes: &[Value],
179    global_produces: &[Value],
180) -> Map<String, Value> {
181    let mut converted = Map::new();
182
183    // Get operation-level consumes/produces or fall back to global
184    let consumes: Vec<String> = operation
185        .get("consumes")
186        .and_then(|v| v.as_array())
187        .map(|arr| arr.iter())
188        .unwrap_or_else(|| global_consumes.iter())
189        .filter_map(|v| v.as_str().map(String::from))
190        .collect::<Vec<_>>();
191    let consumes = if consumes.is_empty() {
192        vec!["application/json".to_string()]
193    } else {
194        consumes
195    };
196
197    let produces: Vec<String> = operation
198        .get("produces")
199        .and_then(|v| v.as_array())
200        .map(|arr| arr.iter())
201        .unwrap_or_else(|| global_produces.iter())
202        .filter_map(|v| v.as_str().map(String::from))
203        .collect::<Vec<_>>();
204    let produces = if produces.is_empty() {
205        vec!["application/json".to_string()]
206    } else {
207        produces
208    };
209
210    // Process parameters - separate body and formData parameters for requestBody
211    let mut non_body_params = Vec::new();
212    let mut body_param: Option<&Value> = None;
213    let mut form_data_params = Vec::new();
214
215    if let Some(params) = operation.get("parameters").and_then(|v| v.as_array()) {
216        for param in params {
217            match param.get("in").and_then(|v| v.as_str()) {
218                Some("body") => body_param = Some(param),
219                Some("formData") => form_data_params.push(param),
220                _ => non_body_params.push(convert_parameter(param)),
221            }
222        }
223    }
224
225    // Add converted parameters
226    if !non_body_params.is_empty() {
227        converted.insert("parameters".to_string(), json!(non_body_params));
228    }
229
230    // Convert body parameter to requestBody
231    if let Some(body) = body_param {
232        let request_body = convert_body_to_request_body(body, &consumes);
233        converted.insert("requestBody".to_string(), request_body);
234    } else if !form_data_params.is_empty() {
235        // Convert formData parameters to requestBody with form encoding
236        let mut properties = serde_json::Map::new();
237        let mut required = Vec::new();
238        let mut has_file = false;
239        for param in &form_data_params {
240            if let Some(name) = param.get("name").and_then(|v| v.as_str()) {
241                let mut prop = serde_json::Map::new();
242                if let Some(typ) = param.get("type").and_then(|v| v.as_str()) {
243                    if typ == "file" {
244                        prop.insert("type".to_string(), json!("string"));
245                        prop.insert("format".to_string(), json!("binary"));
246                        has_file = true;
247                    } else {
248                        prop.insert("type".to_string(), json!(typ));
249                    }
250                }
251                if let Some(desc) = param.get("description") {
252                    prop.insert("description".to_string(), desc.clone());
253                }
254                properties.insert(name.to_string(), json!(prop));
255                if param.get("required").and_then(|v| v.as_bool()).unwrap_or(false) {
256                    required.push(json!(name));
257                }
258            }
259        }
260        let content_type = if has_file {
261            "multipart/form-data"
262        } else {
263            "application/x-www-form-urlencoded"
264        };
265        let mut schema = serde_json::Map::new();
266        schema.insert("type".to_string(), json!("object"));
267        schema.insert("properties".to_string(), json!(properties));
268        if !required.is_empty() {
269            schema.insert("required".to_string(), json!(required));
270        }
271        converted.insert(
272            "requestBody".to_string(),
273            json!({
274                "content": {
275                    content_type: {
276                        "schema": schema
277                    }
278                }
279            }),
280        );
281    }
282
283    // Convert responses
284    if let Some(responses) = operation.get("responses") {
285        let converted_responses = convert_responses(responses, &produces);
286        converted.insert("responses".to_string(), converted_responses);
287    }
288
289    // Copy other fields
290    for (key, value) in operation {
291        match key.as_str() {
292            "parameters" | "responses" | "consumes" | "produces" => {
293                // Already handled
294            }
295            _ => {
296                converted.insert(key.clone(), value.clone());
297            }
298        }
299    }
300
301    converted
302}
303
304/// Convert a Swagger 2.0 parameter to OpenAPI 3.0 format
305fn convert_parameter(param: &Value) -> Value {
306    let Some(param_obj) = param.as_object() else {
307        return param.clone();
308    };
309
310    let mut converted = Map::new();
311
312    // Copy basic fields
313    for key in &["name", "in", "description", "required", "allowEmptyValue"] {
314        if let Some(value) = param_obj.get(*key) {
315            converted.insert(key.to_string(), value.clone());
316        }
317    }
318
319    // Convert type/format/items to schema
320    let param_in = param_obj.get("in").and_then(|v| v.as_str());
321
322    // Skip body parameters (handled separately) and formData (converted to requestBody)
323    if param_in == Some("body") || param_in == Some("formData") {
324        return param.clone();
325    }
326
327    // Build schema from type/format/items/enum/default
328    let mut schema = Map::new();
329
330    if let Some(param_type) = param_obj.get("type") {
331        schema.insert("type".to_string(), param_type.clone());
332    }
333    if let Some(format) = param_obj.get("format") {
334        schema.insert("format".to_string(), format.clone());
335    }
336    if let Some(items) = param_obj.get("items") {
337        schema.insert("items".to_string(), items.clone());
338    }
339    if let Some(enum_values) = param_obj.get("enum") {
340        schema.insert("enum".to_string(), enum_values.clone());
341    }
342    if let Some(default) = param_obj.get("default") {
343        schema.insert("default".to_string(), default.clone());
344    }
345    if let Some(minimum) = param_obj.get("minimum") {
346        schema.insert("minimum".to_string(), minimum.clone());
347    }
348    if let Some(maximum) = param_obj.get("maximum") {
349        schema.insert("maximum".to_string(), maximum.clone());
350    }
351    if let Some(pattern) = param_obj.get("pattern") {
352        schema.insert("pattern".to_string(), pattern.clone());
353    }
354
355    if !schema.is_empty() {
356        converted.insert("schema".to_string(), Value::Object(schema));
357    }
358
359    Value::Object(converted)
360}
361
362/// Convert body parameter to OpenAPI 3.0 requestBody
363fn convert_body_to_request_body(body: &Value, consumes: &[String]) -> Value {
364    let mut request_body = Map::new();
365
366    if let Some(desc) = body.get("description") {
367        request_body.insert("description".to_string(), desc.clone());
368    }
369
370    if let Some(required) = body.get("required") {
371        request_body.insert("required".to_string(), required.clone());
372    }
373
374    // Build content section
375    let mut content = Map::new();
376    let schema = body.get("schema").cloned().unwrap_or(json!({}));
377
378    for media_type in consumes {
379        content.insert(
380            media_type.clone(),
381            json!({
382                "schema": convert_schema_refs(&schema)
383            }),
384        );
385    }
386
387    request_body.insert("content".to_string(), Value::Object(content));
388
389    Value::Object(request_body)
390}
391
392/// Convert Swagger 2.0 responses to OpenAPI 3.0 format
393fn convert_responses(responses: &Value, produces: &[String]) -> Value {
394    let Some(responses_obj) = responses.as_object() else {
395        return responses.clone();
396    };
397
398    let mut converted = Map::new();
399
400    for (status_code, response) in responses_obj {
401        if let Some(response_obj) = response.as_object() {
402            let converted_response = convert_response(response_obj, produces);
403            converted.insert(status_code.clone(), Value::Object(converted_response));
404        }
405    }
406
407    Value::Object(converted)
408}
409
410/// Convert a single response
411fn convert_response(response: &Map<String, Value>, produces: &[String]) -> Map<String, Value> {
412    let mut converted = Map::new();
413
414    // Copy description (required in OpenAPI 3.0)
415    if let Some(desc) = response.get("description") {
416        converted.insert("description".to_string(), desc.clone());
417    } else {
418        converted.insert("description".to_string(), json!("Response"));
419    }
420
421    // Convert schema to content
422    if let Some(schema) = response.get("schema") {
423        let mut content = Map::new();
424        for media_type in produces {
425            content.insert(
426                media_type.clone(),
427                json!({
428                    "schema": convert_schema_refs(schema)
429                }),
430            );
431        }
432        converted.insert("content".to_string(), Value::Object(content));
433    }
434
435    // Convert headers
436    if let Some(headers) = response.get("headers") {
437        if let Some(headers_obj) = headers.as_object() {
438            let mut converted_headers = Map::new();
439            for (name, header) in headers_obj {
440                converted_headers.insert(name.clone(), convert_header(header));
441            }
442            converted.insert("headers".to_string(), Value::Object(converted_headers));
443        }
444    }
445
446    // Copy examples (if any)
447    if let Some(examples) = response.get("examples") {
448        converted.insert("examples".to_string(), examples.clone());
449    }
450
451    converted
452}
453
454/// Convert a header definition
455fn convert_header(header: &Value) -> Value {
456    let Some(header_obj) = header.as_object() else {
457        return header.clone();
458    };
459
460    let mut converted = Map::new();
461
462    if let Some(desc) = header_obj.get("description") {
463        converted.insert("description".to_string(), desc.clone());
464    }
465
466    // Build schema from type/format
467    let mut schema = Map::new();
468    if let Some(header_type) = header_obj.get("type") {
469        schema.insert("type".to_string(), header_type.clone());
470    }
471    if let Some(format) = header_obj.get("format") {
472        schema.insert("format".to_string(), format.clone());
473    }
474
475    if !schema.is_empty() {
476        converted.insert("schema".to_string(), Value::Object(schema));
477    }
478
479    Value::Object(converted)
480}
481
482/// Convert Swagger 2.0 security definitions to OpenAPI 3.0 security schemes
483fn convert_security_definitions(security_defs: &Value) -> Value {
484    let Some(defs_obj) = security_defs.as_object() else {
485        return security_defs.clone();
486    };
487
488    let mut converted = Map::new();
489
490    for (name, def) in defs_obj {
491        if let Some(def_obj) = def.as_object() {
492            let converted_def = convert_security_definition(def_obj);
493            converted.insert(name.clone(), Value::Object(converted_def));
494        }
495    }
496
497    Value::Object(converted)
498}
499
500/// Convert a single security definition
501fn convert_security_definition(def: &Map<String, Value>) -> Map<String, Value> {
502    let mut converted = Map::new();
503
504    let security_type = def.get("type").and_then(|v| v.as_str()).unwrap_or("");
505
506    match security_type {
507        "basic" => {
508            converted.insert("type".to_string(), json!("http"));
509            converted.insert("scheme".to_string(), json!("basic"));
510        }
511        "apiKey" => {
512            converted.insert("type".to_string(), json!("apiKey"));
513            if let Some(name) = def.get("name") {
514                converted.insert("name".to_string(), name.clone());
515            }
516            if let Some(in_val) = def.get("in") {
517                converted.insert("in".to_string(), in_val.clone());
518            }
519        }
520        "oauth2" => {
521            converted.insert("type".to_string(), json!("oauth2"));
522
523            // Convert OAuth2 flow
524            let flow = def.get("flow").and_then(|v| v.as_str()).unwrap_or("implicit");
525            let mut flows = Map::new();
526
527            let mut flow_obj = Map::new();
528
529            // Map Swagger 2.0 flow types to OpenAPI 3.0
530            let flow_name = match flow {
531                "implicit" => "implicit",
532                "password" => "password",
533                "application" => "clientCredentials",
534                "accessCode" => "authorizationCode",
535                _ => "implicit",
536            };
537
538            if let Some(auth_url) = def.get("authorizationUrl") {
539                flow_obj.insert("authorizationUrl".to_string(), auth_url.clone());
540            }
541            if let Some(token_url) = def.get("tokenUrl") {
542                flow_obj.insert("tokenUrl".to_string(), token_url.clone());
543            }
544            if let Some(scopes) = def.get("scopes") {
545                flow_obj.insert("scopes".to_string(), scopes.clone());
546            } else {
547                flow_obj.insert("scopes".to_string(), json!({}));
548            }
549
550            flows.insert(flow_name.to_string(), Value::Object(flow_obj));
551            converted.insert("flows".to_string(), Value::Object(flows));
552        }
553        _ => {
554            // Unknown type, copy as-is
555            for (key, value) in def {
556                converted.insert(key.clone(), value.clone());
557            }
558        }
559    }
560
561    // Copy description if present
562    if let Some(desc) = def.get("description") {
563        converted.insert("description".to_string(), desc.clone());
564    }
565
566    converted
567}
568
569/// Convert $ref paths from Swagger 2.0 to OpenAPI 3.0 format
570fn convert_ref(ref_str: &str) -> String {
571    // #/definitions/Foo -> #/components/schemas/Foo
572    if let Some(name) = ref_str.strip_prefix("#/definitions/") {
573        format!("#/components/schemas/{}", name)
574    } else {
575        ref_str.to_string()
576    }
577}
578
579/// Recursively convert $ref in schema objects
580fn convert_schema_refs(schema: &Value) -> Value {
581    match schema {
582        Value::Object(obj) => {
583            let mut converted = Map::new();
584            for (key, value) in obj {
585                if key == "$ref" {
586                    if let Some(ref_str) = value.as_str() {
587                        converted.insert(key.clone(), json!(convert_ref(ref_str)));
588                    } else {
589                        converted.insert(key.clone(), value.clone());
590                    }
591                } else {
592                    converted.insert(key.clone(), convert_schema_refs(value));
593                }
594            }
595            Value::Object(converted)
596        }
597        Value::Array(arr) => Value::Array(arr.iter().map(convert_schema_refs).collect()),
598        _ => schema.clone(),
599    }
600}
601
602/// Check if a JSON value is a Swagger 2.0 specification
603pub fn is_swagger_2(value: &Value) -> bool {
604    value.get("swagger").and_then(|v| v.as_str()) == Some("2.0")
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610
611    #[test]
612    fn test_is_swagger_2() {
613        assert!(is_swagger_2(&json!({"swagger": "2.0"})));
614        assert!(!is_swagger_2(&json!({"openapi": "3.0.0"})));
615    }
616
617    #[test]
618    fn test_convert_servers() {
619        let swagger = json!({
620            "swagger": "2.0",
621            "host": "api.example.com",
622            "basePath": "/v1",
623            "schemes": ["https", "http"]
624        });
625
626        let servers = convert_servers(&swagger);
627        assert_eq!(servers.len(), 2);
628        assert_eq!(servers[0]["url"], "https://api.example.com/v1");
629        assert_eq!(servers[1]["url"], "http://api.example.com/v1");
630    }
631
632    #[test]
633    fn test_convert_ref() {
634        assert_eq!(convert_ref("#/definitions/User"), "#/components/schemas/User");
635        assert_eq!(convert_ref("#/components/schemas/User"), "#/components/schemas/User");
636    }
637
638    #[test]
639    fn test_convert_parameter() {
640        let param = json!({
641            "name": "userId",
642            "in": "path",
643            "required": true,
644            "type": "string",
645            "format": "uuid"
646        });
647
648        let converted = convert_parameter(&param);
649        assert_eq!(converted["name"], "userId");
650        assert_eq!(converted["in"], "path");
651        assert_eq!(converted["schema"]["type"], "string");
652        assert_eq!(converted["schema"]["format"], "uuid");
653    }
654
655    #[test]
656    fn test_convert_security_basic() {
657        let def = json!({
658            "type": "basic",
659            "description": "Basic auth"
660        });
661
662        if let Some(def_obj) = def.as_object() {
663            let converted = convert_security_definition(def_obj);
664            assert_eq!(converted["type"], json!("http"));
665            assert_eq!(converted["scheme"], json!("basic"));
666        }
667    }
668
669    #[test]
670    fn test_basic_conversion() {
671        let swagger = json!({
672            "swagger": "2.0",
673            "info": {
674                "title": "Test API",
675                "version": "1.0.0"
676            },
677            "host": "api.example.com",
678            "basePath": "/v1",
679            "schemes": ["https"],
680            "paths": {
681                "/users": {
682                    "get": {
683                        "operationId": "getUsers",
684                        "produces": ["application/json"],
685                        "responses": {
686                            "200": {
687                                "description": "Success"
688                            }
689                        }
690                    }
691                }
692            }
693        });
694
695        let result = convert_swagger_to_openapi3(&swagger).unwrap();
696        assert_eq!(result["openapi"], "3.0.3");
697        assert_eq!(result["info"]["title"], "Test API");
698        assert!(result["servers"].as_array().is_some());
699        assert!(result["paths"]["/users"]["get"].is_object());
700    }
701}