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 parameters for requestBody
211    let mut non_body_params = Vec::new();
212    let mut body_param: Option<&Value> = None;
213
214    if let Some(params) = operation.get("parameters").and_then(|v| v.as_array()) {
215        for param in params {
216            if param.get("in").and_then(|v| v.as_str()) == Some("body") {
217                body_param = Some(param);
218            } else {
219                non_body_params.push(convert_parameter(param));
220            }
221        }
222    }
223
224    // Add converted parameters
225    if !non_body_params.is_empty() {
226        converted.insert("parameters".to_string(), json!(non_body_params));
227    }
228
229    // Convert body parameter to requestBody
230    if let Some(body) = body_param {
231        let request_body = convert_body_to_request_body(body, &consumes);
232        converted.insert("requestBody".to_string(), request_body);
233    }
234
235    // Convert responses
236    if let Some(responses) = operation.get("responses") {
237        let converted_responses = convert_responses(responses, &produces);
238        converted.insert("responses".to_string(), converted_responses);
239    }
240
241    // Copy other fields
242    for (key, value) in operation {
243        match key.as_str() {
244            "parameters" | "responses" | "consumes" | "produces" => {
245                // Already handled
246            }
247            _ => {
248                converted.insert(key.clone(), value.clone());
249            }
250        }
251    }
252
253    converted
254}
255
256/// Convert a Swagger 2.0 parameter to OpenAPI 3.0 format
257fn convert_parameter(param: &Value) -> Value {
258    let Some(param_obj) = param.as_object() else {
259        return param.clone();
260    };
261
262    let mut converted = Map::new();
263
264    // Copy basic fields
265    for key in &["name", "in", "description", "required", "allowEmptyValue"] {
266        if let Some(value) = param_obj.get(*key) {
267            converted.insert(key.to_string(), value.clone());
268        }
269    }
270
271    // Convert type/format/items to schema
272    let param_in = param_obj.get("in").and_then(|v| v.as_str());
273
274    // Skip body parameters (handled separately) and formData (converted to requestBody)
275    if param_in == Some("body") || param_in == Some("formData") {
276        return param.clone();
277    }
278
279    // Build schema from type/format/items/enum/default
280    let mut schema = Map::new();
281
282    if let Some(param_type) = param_obj.get("type") {
283        schema.insert("type".to_string(), param_type.clone());
284    }
285    if let Some(format) = param_obj.get("format") {
286        schema.insert("format".to_string(), format.clone());
287    }
288    if let Some(items) = param_obj.get("items") {
289        schema.insert("items".to_string(), items.clone());
290    }
291    if let Some(enum_values) = param_obj.get("enum") {
292        schema.insert("enum".to_string(), enum_values.clone());
293    }
294    if let Some(default) = param_obj.get("default") {
295        schema.insert("default".to_string(), default.clone());
296    }
297    if let Some(minimum) = param_obj.get("minimum") {
298        schema.insert("minimum".to_string(), minimum.clone());
299    }
300    if let Some(maximum) = param_obj.get("maximum") {
301        schema.insert("maximum".to_string(), maximum.clone());
302    }
303    if let Some(pattern) = param_obj.get("pattern") {
304        schema.insert("pattern".to_string(), pattern.clone());
305    }
306
307    if !schema.is_empty() {
308        converted.insert("schema".to_string(), Value::Object(schema));
309    }
310
311    Value::Object(converted)
312}
313
314/// Convert body parameter to OpenAPI 3.0 requestBody
315fn convert_body_to_request_body(body: &Value, consumes: &[String]) -> Value {
316    let mut request_body = Map::new();
317
318    if let Some(desc) = body.get("description") {
319        request_body.insert("description".to_string(), desc.clone());
320    }
321
322    if let Some(required) = body.get("required") {
323        request_body.insert("required".to_string(), required.clone());
324    }
325
326    // Build content section
327    let mut content = Map::new();
328    let schema = body.get("schema").cloned().unwrap_or(json!({}));
329
330    for media_type in consumes {
331        content.insert(
332            media_type.clone(),
333            json!({
334                "schema": convert_schema_refs(&schema)
335            }),
336        );
337    }
338
339    request_body.insert("content".to_string(), Value::Object(content));
340
341    Value::Object(request_body)
342}
343
344/// Convert Swagger 2.0 responses to OpenAPI 3.0 format
345fn convert_responses(responses: &Value, produces: &[String]) -> Value {
346    let Some(responses_obj) = responses.as_object() else {
347        return responses.clone();
348    };
349
350    let mut converted = Map::new();
351
352    for (status_code, response) in responses_obj {
353        if let Some(response_obj) = response.as_object() {
354            let converted_response = convert_response(response_obj, produces);
355            converted.insert(status_code.clone(), Value::Object(converted_response));
356        }
357    }
358
359    Value::Object(converted)
360}
361
362/// Convert a single response
363fn convert_response(response: &Map<String, Value>, produces: &[String]) -> Map<String, Value> {
364    let mut converted = Map::new();
365
366    // Copy description (required in OpenAPI 3.0)
367    if let Some(desc) = response.get("description") {
368        converted.insert("description".to_string(), desc.clone());
369    } else {
370        converted.insert("description".to_string(), json!("Response"));
371    }
372
373    // Convert schema to content
374    if let Some(schema) = response.get("schema") {
375        let mut content = Map::new();
376        for media_type in produces {
377            content.insert(
378                media_type.clone(),
379                json!({
380                    "schema": convert_schema_refs(schema)
381                }),
382            );
383        }
384        converted.insert("content".to_string(), Value::Object(content));
385    }
386
387    // Convert headers
388    if let Some(headers) = response.get("headers") {
389        if let Some(headers_obj) = headers.as_object() {
390            let mut converted_headers = Map::new();
391            for (name, header) in headers_obj {
392                converted_headers.insert(name.clone(), convert_header(header));
393            }
394            converted.insert("headers".to_string(), Value::Object(converted_headers));
395        }
396    }
397
398    // Copy examples (if any)
399    if let Some(examples) = response.get("examples") {
400        converted.insert("examples".to_string(), examples.clone());
401    }
402
403    converted
404}
405
406/// Convert a header definition
407fn convert_header(header: &Value) -> Value {
408    let Some(header_obj) = header.as_object() else {
409        return header.clone();
410    };
411
412    let mut converted = Map::new();
413
414    if let Some(desc) = header_obj.get("description") {
415        converted.insert("description".to_string(), desc.clone());
416    }
417
418    // Build schema from type/format
419    let mut schema = Map::new();
420    if let Some(header_type) = header_obj.get("type") {
421        schema.insert("type".to_string(), header_type.clone());
422    }
423    if let Some(format) = header_obj.get("format") {
424        schema.insert("format".to_string(), format.clone());
425    }
426
427    if !schema.is_empty() {
428        converted.insert("schema".to_string(), Value::Object(schema));
429    }
430
431    Value::Object(converted)
432}
433
434/// Convert Swagger 2.0 security definitions to OpenAPI 3.0 security schemes
435fn convert_security_definitions(security_defs: &Value) -> Value {
436    let Some(defs_obj) = security_defs.as_object() else {
437        return security_defs.clone();
438    };
439
440    let mut converted = Map::new();
441
442    for (name, def) in defs_obj {
443        if let Some(def_obj) = def.as_object() {
444            let converted_def = convert_security_definition(def_obj);
445            converted.insert(name.clone(), Value::Object(converted_def));
446        }
447    }
448
449    Value::Object(converted)
450}
451
452/// Convert a single security definition
453fn convert_security_definition(def: &Map<String, Value>) -> Map<String, Value> {
454    let mut converted = Map::new();
455
456    let security_type = def.get("type").and_then(|v| v.as_str()).unwrap_or("");
457
458    match security_type {
459        "basic" => {
460            converted.insert("type".to_string(), json!("http"));
461            converted.insert("scheme".to_string(), json!("basic"));
462        }
463        "apiKey" => {
464            converted.insert("type".to_string(), json!("apiKey"));
465            if let Some(name) = def.get("name") {
466                converted.insert("name".to_string(), name.clone());
467            }
468            if let Some(in_val) = def.get("in") {
469                converted.insert("in".to_string(), in_val.clone());
470            }
471        }
472        "oauth2" => {
473            converted.insert("type".to_string(), json!("oauth2"));
474
475            // Convert OAuth2 flow
476            let flow = def.get("flow").and_then(|v| v.as_str()).unwrap_or("implicit");
477            let mut flows = Map::new();
478
479            let mut flow_obj = Map::new();
480
481            // Map Swagger 2.0 flow types to OpenAPI 3.0
482            let flow_name = match flow {
483                "implicit" => "implicit",
484                "password" => "password",
485                "application" => "clientCredentials",
486                "accessCode" => "authorizationCode",
487                _ => "implicit",
488            };
489
490            if let Some(auth_url) = def.get("authorizationUrl") {
491                flow_obj.insert("authorizationUrl".to_string(), auth_url.clone());
492            }
493            if let Some(token_url) = def.get("tokenUrl") {
494                flow_obj.insert("tokenUrl".to_string(), token_url.clone());
495            }
496            if let Some(scopes) = def.get("scopes") {
497                flow_obj.insert("scopes".to_string(), scopes.clone());
498            } else {
499                flow_obj.insert("scopes".to_string(), json!({}));
500            }
501
502            flows.insert(flow_name.to_string(), Value::Object(flow_obj));
503            converted.insert("flows".to_string(), Value::Object(flows));
504        }
505        _ => {
506            // Unknown type, copy as-is
507            for (key, value) in def {
508                converted.insert(key.clone(), value.clone());
509            }
510        }
511    }
512
513    // Copy description if present
514    if let Some(desc) = def.get("description") {
515        converted.insert("description".to_string(), desc.clone());
516    }
517
518    converted
519}
520
521/// Convert $ref paths from Swagger 2.0 to OpenAPI 3.0 format
522fn convert_ref(ref_str: &str) -> String {
523    // #/definitions/Foo -> #/components/schemas/Foo
524    if let Some(name) = ref_str.strip_prefix("#/definitions/") {
525        format!("#/components/schemas/{}", name)
526    } else {
527        ref_str.to_string()
528    }
529}
530
531/// Recursively convert $ref in schema objects
532fn convert_schema_refs(schema: &Value) -> Value {
533    match schema {
534        Value::Object(obj) => {
535            let mut converted = Map::new();
536            for (key, value) in obj {
537                if key == "$ref" {
538                    if let Some(ref_str) = value.as_str() {
539                        converted.insert(key.clone(), json!(convert_ref(ref_str)));
540                    } else {
541                        converted.insert(key.clone(), value.clone());
542                    }
543                } else {
544                    converted.insert(key.clone(), convert_schema_refs(value));
545                }
546            }
547            Value::Object(converted)
548        }
549        Value::Array(arr) => Value::Array(arr.iter().map(convert_schema_refs).collect()),
550        _ => schema.clone(),
551    }
552}
553
554/// Check if a JSON value is a Swagger 2.0 specification
555pub fn is_swagger_2(value: &Value) -> bool {
556    value.get("swagger").and_then(|v| v.as_str()) == Some("2.0")
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    #[test]
564    fn test_is_swagger_2() {
565        assert!(is_swagger_2(&json!({"swagger": "2.0"})));
566        assert!(!is_swagger_2(&json!({"openapi": "3.0.0"})));
567    }
568
569    #[test]
570    fn test_convert_servers() {
571        let swagger = json!({
572            "swagger": "2.0",
573            "host": "api.example.com",
574            "basePath": "/v1",
575            "schemes": ["https", "http"]
576        });
577
578        let servers = convert_servers(&swagger);
579        assert_eq!(servers.len(), 2);
580        assert_eq!(servers[0]["url"], "https://api.example.com/v1");
581        assert_eq!(servers[1]["url"], "http://api.example.com/v1");
582    }
583
584    #[test]
585    fn test_convert_ref() {
586        assert_eq!(convert_ref("#/definitions/User"), "#/components/schemas/User");
587        assert_eq!(convert_ref("#/components/schemas/User"), "#/components/schemas/User");
588    }
589
590    #[test]
591    fn test_convert_parameter() {
592        let param = json!({
593            "name": "userId",
594            "in": "path",
595            "required": true,
596            "type": "string",
597            "format": "uuid"
598        });
599
600        let converted = convert_parameter(&param);
601        assert_eq!(converted["name"], "userId");
602        assert_eq!(converted["in"], "path");
603        assert_eq!(converted["schema"]["type"], "string");
604        assert_eq!(converted["schema"]["format"], "uuid");
605    }
606
607    #[test]
608    fn test_convert_security_basic() {
609        let def = json!({
610            "type": "basic",
611            "description": "Basic auth"
612        });
613
614        if let Some(def_obj) = def.as_object() {
615            let converted = convert_security_definition(def_obj);
616            assert_eq!(converted["type"], json!("http"));
617            assert_eq!(converted["scheme"], json!("basic"));
618        }
619    }
620
621    #[test]
622    fn test_basic_conversion() {
623        let swagger = json!({
624            "swagger": "2.0",
625            "info": {
626                "title": "Test API",
627                "version": "1.0.0"
628            },
629            "host": "api.example.com",
630            "basePath": "/v1",
631            "schemes": ["https"],
632            "paths": {
633                "/users": {
634                    "get": {
635                        "operationId": "getUsers",
636                        "produces": ["application/json"],
637                        "responses": {
638                            "200": {
639                                "description": "Success"
640                            }
641                        }
642                    }
643                }
644            }
645        });
646
647        let result = convert_swagger_to_openapi3(&swagger).unwrap();
648        assert_eq!(result["openapi"], "3.0.3");
649        assert_eq!(result["info"]["title"], "Test API");
650        assert!(result["servers"].as_array().is_some());
651        assert!(result["paths"]["/users"]["get"].is_object());
652    }
653}