Skip to main content

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