src/openapi/swagger2.rs 31.6 K raw
1
//! Swagger 2.0 → OpenAPI 3.0 normalisation.
2
//!
3
//! Rather than teach every downstream module two spec dialects, a 2.0 document
4
//! is rewritten into the 3.0 shape as it's parsed ([`super::loader::parse_spec`]
5
//! calls [`to_openapi3`]), so `import`, `examples` and `docs` only ever see
6
//! 3.x. The conversion is structural and lossy in the places 3.0 has no slot
7
//! for — see the module's `convert_*` docs.
8
9
use serde_json::{Map, Value};
10
11
use super::resolve::deref;
12
13
const METHODS: [&str; 7] = ["get", "post", "put", "patch", "delete", "head", "options"];
14
15
/// Keys 2.0 puts directly on a non-body parameter (or a response header) that
16
/// 3.0 nests under `schema`.
17
const SCHEMA_KEYS: [&str; 16] = [
18
    "type",
19
    "format",
20
    "items",
21
    "default",
22
    "enum",
23
    "maximum",
24
    "exclusiveMaximum",
25
    "minimum",
26
    "exclusiveMinimum",
27
    "maxLength",
28
    "minLength",
29
    "pattern",
30
    "maxItems",
31
    "minItems",
32
    "uniqueItems",
33
    "multipleOf",
34
];
35
36
/// Where the 2.0 definition sections move to under `components`, and therefore
37
/// how `$ref`s into them have to be rewritten.
38
const REF_MOVES: [(&str, &str); 3] = [
39
    ("#/definitions/", "#/components/schemas/"),
40
    ("#/parameters/", "#/components/parameters/"),
41
    ("#/responses/", "#/components/responses/"),
42
];
43
44
const DEFAULT_MEDIA: &str = "application/json";
45
const FORM_MEDIA: &str = "application/x-www-form-urlencoded";
46
const MULTIPART_MEDIA: &str = "multipart/form-data";
47
48
/// Is this a Swagger 2.0 document? 3.x documents carry `openapi` instead.
49
pub fn is_swagger2(doc: &Value) -> bool {
50
    doc.get("swagger")
51
        .and_then(Value::as_str)
52
        .is_some_and(|v| v.starts_with("2."))
53
}
54
55
/// Rewrite a Swagger 2.0 document into an equivalent OpenAPI 3.0 one.
56
/// Anything the conversion doesn't recognise is carried across untouched, so
57
/// vendor extensions and unknown fields survive.
58
pub fn to_openapi3(doc: Value) -> Value {
59
    let mut doc = doc;
60
    rewrite_refs(&mut doc);
61
    let mut root = match doc {
62
        Value::Object(map) => map,
63
        other => return other,
64
    };
65
66
    root.remove("swagger");
67
    let consumes = string_list(root.remove("consumes").as_ref());
68
    let produces = string_list(root.remove("produces").as_ref());
69
    let servers = servers_from(&mut root);
70
    let components = build_components(&mut root, &produces);
71
72
    // Parameters are referenced by `$ref` from operations, and whether one is a
73
    // body parameter decides where it lands — so operation conversion needs to
74
    // resolve against the components built above.
75
    let mut refdoc = Map::new();
76
    refdoc.insert("components".into(), components.clone());
77
    let refdoc = Value::Object(refdoc);
78
79
    let paths = convert_paths(root.remove("paths"), &refdoc, &consumes, &produces);
80
81
    let mut out = Map::new();
82
    out.insert("openapi".into(), Value::from("3.0.0"));
83
    // Whatever is left (info, security, tags, externalDocs, x-…) keeps its 2.0
84
    // meaning in 3.0.
85
    for (key, value) in root {
86
        out.insert(key, value);
87
    }
88
    if !servers.is_empty() {
89
        out.insert("servers".into(), Value::Array(servers));
90
    }
91
    out.insert("paths".into(), paths);
92
    if components.as_object().is_some_and(|c| !c.is_empty()) {
93
        out.insert("components".into(), components);
94
    }
95
    Value::Object(out)
96
}
97
98
/// Point every local `$ref` at its new home under `components`.
99
fn rewrite_refs(node: &mut Value) {
100
    match node {
101
        Value::Object(map) => {
102
            if let Some(Value::String(reference)) = map.get_mut("$ref") {
103
                for (from, to) in REF_MOVES {
104
                    if let Some(rest) = reference.strip_prefix(from) {
105
                        *reference = format!("{to}{rest}");
106
                        break;
107
                    }
108
                }
109
            }
110
            for (_, child) in map.iter_mut() {
111
                rewrite_refs(child);
112
            }
113
        }
114
        Value::Array(items) => items.iter_mut().for_each(rewrite_refs),
115
        _ => {}
116
    }
117
}
118
119
/// `schemes` × `host` + `basePath` → `servers`. `https` is ordered first when a
120
/// spec offers both, since that's the better default active server; non-HTTP
121
/// schemes (`ws`, `wss`) are dropped. A spec with only a `basePath` yields the
122
/// relative URL it implies, which the user can replace at import time.
123
fn servers_from(root: &mut Map<String, Value>) -> Vec<Value> {
124
    let host = take_str(root, "host");
125
    let base = take_str(root, "basePath");
126
    let base = base.trim_end_matches('/').to_string();
127
128
    if host.is_empty() {
129
        return if base.is_empty() {
130
            Vec::new()
131
        } else {
132
            vec![server(&base)]
133
        };
134
    }
135
136
    let mut schemes = string_list(root.remove("schemes").as_ref());
137
    schemes.retain(|s| s == "http" || s == "https");
138
    schemes.sort_by_key(|s| usize::from(s != "https"));
139
    schemes.dedup();
140
    if schemes.is_empty() {
141
        schemes.push("https".into());
142
    }
143
    schemes
144
        .iter()
145
        .map(|scheme| server(&format!("{scheme}://{host}{base}")))
146
        .collect()
147
}
148
149
fn server(url: &str) -> Value {
150
    let mut map = Map::new();
151
    map.insert("url".into(), Value::from(url));
152
    Value::Object(map)
153
}
154
155
/// Move the 2.0 top-level definition sections under `components`, converting
156
/// each entry to its 3.0 shape. Shared body/formData parameters are left in 2.0
157
/// form: they have no 3.0 counterpart, and [`split_params`] lifts them into a
158
/// `requestBody` wherever they're referenced.
159
fn build_components(root: &mut Map<String, Value>, produces: &[String]) -> Value {
160
    let mut components = Map::new();
161
162
    if let Some(definitions) = root.remove("definitions") {
163
        components.insert("schemas".into(), definitions);
164
    }
165
    if let Some(Value::Object(params)) = root.remove("parameters") {
166
        let converted = params
167
            .into_iter()
168
            .map(|(name, p)| {
169
                let value = if is_payload_param(&p) {
170
                    p
171
                } else {
172
                    convert_param(p)
173
                };
174
                (name, value)
175
            })
176
            .collect();
177
        components.insert("parameters".into(), Value::Object(converted));
178
    }
179
    if let Some(Value::Object(responses)) = root.remove("responses") {
180
        let converted = responses
181
            .into_iter()
182
            .map(|(code, r)| (code, convert_response(r, produces)))
183
            .collect();
184
        components.insert("responses".into(), Value::Object(converted));
185
    }
186
    if let Some(Value::Object(schemes)) = root.remove("securityDefinitions") {
187
        let converted = schemes
188
            .into_iter()
189
            .map(|(name, s)| (name, convert_security_scheme(s)))
190
            .collect();
191
        components.insert("securitySchemes".into(), Value::Object(converted));
192
    }
193
194
    Value::Object(components)
195
}
196
197
/// 2.0 security definitions → 3.0 security schemes. `basic` becomes HTTP basic;
198
/// oauth2's single `flow` becomes the matching entry in `flows`. `apiKey` is
199
/// already the 3.0 shape.
200
fn convert_security_scheme(scheme: Value) -> Value {
201
    let mut scheme = match scheme {
202
        Value::Object(map) => map,
203
        other => return other,
204
    };
205
    match scheme.get("type").and_then(Value::as_str) {
206
        Some("basic") => {
207
            scheme.insert("type".into(), Value::from("http"));
208
            scheme.insert("scheme".into(), Value::from("basic"));
209
        }
210
        Some("oauth2") => {
211
            let flow_name = match take_str(&mut scheme, "flow").as_str() {
212
                "application" => "clientCredentials",
213
                "accessCode" => "authorizationCode",
214
                "password" => "password",
215
                _ => "implicit",
216
            };
217
            let mut flow = Map::new();
218
            for key in ["authorizationUrl", "tokenUrl", "scopes"] {
219
                if let Some(v) = scheme.remove(key) {
220
                    flow.insert(key.into(), v);
221
                }
222
            }
223
            flow.entry("scopes")
224
                .or_insert_with(|| Value::Object(Map::new()));
225
            let mut flows = Map::new();
226
            flows.insert(flow_name.into(), Value::Object(flow));
227
            scheme.insert("flows".into(), Value::Object(flows));
228
        }
229
        _ => {}
230
    }
231
    Value::Object(scheme)
232
}
233
234
fn convert_paths(
235
    paths: Option<Value>,
236
    refdoc: &Value,
237
    consumes: &[String],
238
    produces: &[String],
239
) -> Value {
240
    let Some(Value::Object(paths)) = paths else {
241
        return Value::Object(Map::new());
242
    };
243
    let mut out = Map::new();
244
    for (path, item) in paths {
245
        let mut item = match item {
246
            Value::Object(map) => map,
247
            other => {
248
                out.insert(path, other);
249
                continue;
250
            }
251
        };
252
        // A path-level body parameter applies to every operation under it.
253
        let (shared_params, shared_body) =
254
            split_params(item.remove("parameters"), refdoc, consumes);
255
        if !shared_params.is_empty() {
256
            item.insert("parameters".into(), Value::Array(shared_params));
257
        }
258
        for method in METHODS {
259
            if let Some(op) = item.remove(method) {
260
                let op = convert_operation(op, refdoc, consumes, produces, shared_body.as_ref());
261
                item.insert(method.into(), op);
262
            }
263
        }
264
        out.insert(path, Value::Object(item));
265
    }
266
    Value::Object(out)
267
}
268
269
/// Operation-level `consumes`/`produces` override the document's, and are the
270
/// media types the lifted `requestBody` and the converted responses are keyed
271
/// by. Operation-level `schemes` is dropped: 3.0 has no per-operation server.
272
fn convert_operation(
273
    op: Value,
274
    refdoc: &Value,
275
    consumes: &[String],
276
    produces: &[String],
277
    shared_body: Option<&Value>,
278
) -> Value {
279
    let mut op = match op {
280
        Value::Object(map) => map,
281
        other => return other,
282
    };
283
284
    let consumes = override_list(op.remove("consumes").as_ref(), consumes);
285
    let produces = override_list(op.remove("produces").as_ref(), produces);
286
    op.remove("schemes");
287
288
    let (params, body) = split_params(op.remove("parameters"), refdoc, &consumes);
289
    if !params.is_empty() {
290
        op.insert("parameters".into(), Value::Array(params));
291
    }
292
    if let Some(body) = body.or_else(|| shared_body.cloned()) {
293
        op.insert("requestBody".into(), body);
294
    }
295
    if let Some(responses) = op.remove("responses") {
296
        op.insert("responses".into(), convert_responses(responses, &produces));
297
    }
298
299
    Value::Object(op)
300
}
301
302
/// Split a 2.0 parameter list into the parameters 3.0 still calls parameters
303
/// and the `requestBody` the rest of them describe. `$ref`s are resolved only
304
/// far enough to tell which side an entry belongs on: a reference to a
305
/// query/header/path parameter is left as a reference.
306
fn split_params(
307
    params: Option<Value>,
308
    refdoc: &Value,
309
    consumes: &[String],
310
) -> (Vec<Value>, Option<Value>) {
311
    let Some(Value::Array(params)) = params else {
312
        return (Vec::new(), None);
313
    };
314
315
    let mut kept = Vec::new();
316
    let mut form = Vec::new();
317
    let mut body = None;
318
    for p in params {
319
        let resolved = deref(refdoc, &p);
320
        match resolved.get("in").and_then(Value::as_str) {
321
            // Last body parameter wins; a spec with two is already invalid.
322
            Some("body") => body = Some(resolved.clone()),
323
            Some("formData") => form.push(resolved.clone()),
324
            _ => kept.push(convert_param(p)),
325
        }
326
    }
327
328
    let request_body = match body {
329
        Some(body) => Some(body_request_body(&body, consumes)),
330
        None if !form.is_empty() => Some(form_request_body(&form, consumes)),
331
        None => None,
332
    };
333
    (kept, request_body)
334
}
335
336
/// A non-body parameter: 2.0 spells its type inline, 3.0 wants a `schema`.
337
fn convert_param(p: Value) -> Value {
338
    let mut p = match p {
339
        Value::Object(map) => map,
340
        other => return other,
341
    };
342
    if p.contains_key("$ref") || p.contains_key("schema") {
343
        return Value::Object(p);
344
    }
345
    // `style`/`explode` are 3.0's answer to collectionFormat; cielago sends
346
    // array params as a single comma-joined value either way.
347
    p.remove("collectionFormat");
348
    if let Some(example) = p.remove("x-example")
349
        && !p.contains_key("example")
350
    {
351
        p.insert("example".into(), example);
352
    }
353
    if let Some(schema) = lift_schema_keys(&mut p) {
354
        p.insert("schema".into(), schema);
355
    }
356
    Value::Object(p)
357
}
358
359
/// `in: body` → `requestBody`, keyed by every media type the operation
360
/// consumes so [`super::import`] can pick the JSON one.
361
fn body_request_body(param: &Value, consumes: &[String]) -> Value {
362
    let schema = param
363
        .get("schema")
364
        .cloned()
365
        .unwrap_or(Value::Object(Map::new()));
366
    let mut content = Map::new();
367
    for media_type in media_types(consumes) {
368
        let mut media = Map::new();
369
        media.insert("schema".into(), schema.clone());
370
        content.insert(media_type, Value::Object(media));
371
    }
372
373
    let mut body = Map::new();
374
    if let Some(description) = param.get("description") {
375
        body.insert("description".into(), description.clone());
376
    }
377
    body.insert(
378
        "required".into(),
379
        Value::from(param.get("required").and_then(Value::as_bool) == Some(true)),
380
    );
381
    body.insert("content".into(), Value::Object(content));
382
    Value::Object(body)
383
}
384
385
/// `in: formData` parameters are one body between them: they become the
386
/// properties of a single object schema, the way 3.0 models a form.
387
fn form_request_body(params: &[Value], consumes: &[String]) -> Value {
388
    let mut properties = Map::new();
389
    let mut required = Vec::new();
390
    let mut has_file = false;
391
392
    for p in params {
393
        let Value::Object(obj) = p else { continue };
394
        let Some(name) = obj.get("name").and_then(Value::as_str).map(String::from) else {
395
            continue;
396
        };
397
        if obj.get("type").and_then(Value::as_str) == Some("file") {
398
            has_file = true;
399
        }
400
        if obj.get("required").and_then(Value::as_bool) == Some(true) {
401
            required.push(Value::from(name.clone()));
402
        }
403
        let mut obj = obj.clone();
404
        let mut schema = match lift_schema_keys(&mut obj) {
405
            Some(Value::Object(schema)) => schema,
406
            _ => Map::new(),
407
        };
408
        if let Some(description) = obj.remove("description") {
409
            schema.entry("description").or_insert(description);
410
        }
411
        properties.insert(name, Value::Object(schema));
412
    }
413
414
    // A file upload has to be multipart; anything else defaults to urlencoded
415
    // unless the spec named a form media type itself.
416
    let wanted = if has_file { "multipart/" } else { "form" };
417
    let media_type = consumes
418
        .iter()
419
        .find(|c| c.contains(wanted))
420
        .cloned()
421
        .unwrap_or_else(|| {
422
            if has_file {
423
                MULTIPART_MEDIA
424
            } else {
425
                FORM_MEDIA
426
            }
427
            .to_string()
428
        });
429
430
    let mut schema = Map::new();
431
    schema.insert("type".into(), Value::from("object"));
432
    if !required.is_empty() {
433
        schema.insert("required".into(), Value::Array(required.clone()));
434
    }
435
    schema.insert("properties".into(), Value::Object(properties));
436
437
    let mut media = Map::new();
438
    media.insert("schema".into(), Value::Object(schema));
439
    let mut content = Map::new();
440
    content.insert(media_type, Value::Object(media));
441
442
    let mut body = Map::new();
443
    body.insert("required".into(), Value::from(!required.is_empty()));
444
    body.insert("content".into(), Value::Object(content));
445
    Value::Object(body)
446
}
447
448
fn convert_responses(responses: Value, produces: &[String]) -> Value {
449
    let Value::Object(responses) = responses else {
450
        return responses;
451
    };
452
    let converted = responses
453
        .into_iter()
454
        .map(|(code, r)| (code, convert_response(r, produces)))
455
        .collect();
456
    Value::Object(converted)
457
}
458
459
/// A 2.0 response carries `schema` (and per-media-type `examples`) directly;
460
/// 3.0 keys both by media type under `content`.
461
fn convert_response(response: Value, produces: &[String]) -> Value {
462
    let mut response = match response {
463
        Value::Object(map) => map,
464
        other => return other,
465
    };
466
    if let Some(Value::Object(headers)) = response.get_mut("headers") {
467
        for (_, header) in headers.iter_mut() {
468
            if let Value::Object(header) = header
469
                && let Some(schema) = lift_schema_keys(header)
470
            {
471
                header.insert("schema".into(), schema);
472
            }
473
        }
474
    }
475
    if response.contains_key("$ref") || response.contains_key("content") {
476
        return Value::Object(response);
477
    }
478
479
    let schema = response.remove("schema");
480
    let examples = response.remove("examples");
481
    if schema.is_none() && examples.is_none() {
482
        return Value::Object(response);
483
    }
484
485
    let mut types = media_types(produces);
486
    if let Some(Value::Object(examples)) = &examples {
487
        for media_type in examples.keys() {
488
            if !types.contains(media_type) {
489
                types.push(media_type.clone());
490
            }
491
        }
492
    }
493
494
    let mut content = Map::new();
495
    for media_type in types {
496
        let mut media = Map::new();
497
        if let Some(schema) = &schema {
498
            media.insert("schema".into(), schema.clone());
499
        }
500
        if let Some(example) = examples.as_ref().and_then(|e| e.get(media_type.as_str())) {
501
            media.insert("example".into(), example.clone());
502
        }
503
        if !media.is_empty() {
504
            content.insert(media_type, Value::Object(media));
505
        }
506
    }
507
    if !content.is_empty() {
508
        response.insert("content".into(), Value::Object(content));
509
    }
510
    Value::Object(response)
511
}
512
513
/// Pull the inline type keywords out of a parameter or header into a schema.
514
/// 2.0's `type: file` is 3.0's binary string.
515
fn lift_schema_keys(obj: &mut Map<String, Value>) -> Option<Value> {
516
    let mut schema = Map::new();
517
    for key in SCHEMA_KEYS {
518
        if let Some(v) = obj.remove(key) {
519
            schema.insert(key.into(), v);
520
        }
521
    }
522
    if schema.is_empty() {
523
        return None;
524
    }
525
    if schema.get("type").and_then(Value::as_str) == Some("file") {
526
        schema.insert("type".into(), Value::from("string"));
527
        schema.insert("format".into(), Value::from("binary"));
528
    }
529
    Some(Value::Object(schema))
530
}
531
532
fn is_payload_param(p: &Value) -> bool {
533
    matches!(
534
        p.get("in").and_then(Value::as_str),
535
        Some("body") | Some("formData")
536
    )
537
}
538
539
fn string_list(v: Option<&Value>) -> Vec<String> {
540
    v.and_then(Value::as_array)
541
        .map(|a| {
542
            a.iter()
543
                .filter_map(Value::as_str)
544
                .map(String::from)
545
                .collect()
546
        })
547
        .unwrap_or_default()
548
}
549
550
/// An operation-level list wins over the document-level one, but only when it
551
/// actually lists something.
552
fn override_list(local: Option<&Value>, inherited: &[String]) -> Vec<String> {
553
    let local = string_list(local);
554
    if local.is_empty() {
555
        inherited.to_vec()
556
    } else {
557
        local
558
    }
559
}
560
561
/// Media types to key a `content` map by, falling back to JSON for a spec that
562
/// declared none.
563
fn media_types(list: &[String]) -> Vec<String> {
564
    if list.is_empty() {
565
        vec![DEFAULT_MEDIA.to_string()]
566
    } else {
567
        list.to_vec()
568
    }
569
}
570
571
fn take_str(map: &mut Map<String, Value>, key: &str) -> String {
572
    map.remove(key)
573
        .and_then(|v| v.as_str().map(str::to_string))
574
        .unwrap_or_default()
575
}
576
577
#[cfg(test)]
578
mod tests {
579
    use super::*;
580
    use serde_json::json;
581
582
    fn convert(doc: Value) -> Value {
583
        assert!(is_swagger2(&doc));
584
        to_openapi3(doc)
585
    }
586
587
    #[test]
588
    fn detects_the_dialect() {
589
        assert!(is_swagger2(&json!({"swagger": "2.0"})));
590
        assert!(!is_swagger2(&json!({"openapi": "3.0.3"})));
591
        assert!(!is_swagger2(&json!({})));
592
    }
593
594
    #[test]
595
    fn host_base_path_and_schemes_become_servers() {
596
        let out = convert(json!({
597
            "swagger": "2.0",
598
            "host": "api.example.com",
599
            "basePath": "/v1/",
600
            "schemes": ["http", "https", "wss"]
601
        }));
602
        // https first, the trailing slash trimmed, non-HTTP schemes dropped.
603
        assert_eq!(
604
            out["servers"],
605
            json!([
606
                {"url": "https://api.example.com/v1"},
607
                {"url": "http://api.example.com/v1"}
608
            ])
609
        );
610
    }
611
612
    #[test]
613
    fn missing_scheme_defaults_to_https_and_missing_host_keeps_base_path() {
614
        let out = convert(json!({"swagger": "2.0", "host": "api.example.com"}));
615
        assert_eq!(out["servers"], json!([{"url": "https://api.example.com"}]));
616
617
        let out = convert(json!({"swagger": "2.0", "basePath": "/v1"}));
618
        assert_eq!(out["servers"], json!([{"url": "/v1"}]));
619
620
        let out = convert(json!({"swagger": "2.0"}));
621
        assert!(out.get("servers").is_none());
622
    }
623
624
    #[test]
625
    fn definitions_move_and_refs_follow_them() {
626
        let out = convert(json!({
627
            "swagger": "2.0",
628
            "definitions": {"Pet": {"type": "object", "properties": {
629
                "friend": {"$ref": "#/definitions/Pet"}
630
            }}},
631
            "paths": {"/pets": {"post": {
632
                "parameters": [{"name": "body", "in": "body", "schema": {"$ref": "#/definitions/Pet"}}],
633
                "responses": {}
634
            }}}
635
        }));
636
        assert_eq!(out["components"]["schemas"]["Pet"]["type"], "object");
637
        assert_eq!(
638
            out["components"]["schemas"]["Pet"]["properties"]["friend"]["$ref"],
639
            "#/components/schemas/Pet"
640
        );
641
        assert_eq!(
642
            out["paths"]["/pets"]["post"]["requestBody"]["content"]["application/json"]["schema"]["$ref"],
643
            "#/components/schemas/Pet"
644
        );
645
    }
646
647
    #[test]
648
    fn body_parameter_becomes_a_request_body() {
649
        let out = convert(json!({
650
            "swagger": "2.0",
651
            "consumes": ["application/xml"],
652
            "paths": {"/pets": {"post": {
653
                "parameters": [
654
                    {"name": "pet", "in": "body", "required": true,
655
                     "description": "the pet", "schema": {"type": "object"}},
656
                    {"name": "trace", "in": "header", "type": "string"}
657
                ],
658
                "responses": {}
659
            }}}
660
        }));
661
        let op = &out["paths"]["/pets"]["post"];
662
        assert_eq!(op["requestBody"]["required"], true);
663
        assert_eq!(op["requestBody"]["description"], "the pet");
664
        assert_eq!(
665
            op["requestBody"]["content"]["application/xml"]["schema"]["type"],
666
            "object"
667
        );
668
        // The body parameter left the parameter list; the header stayed.
669
        assert_eq!(op["parameters"].as_array().unwrap().len(), 1);
670
        assert_eq!(op["parameters"][0]["name"], "trace");
671
    }
672
673
    #[test]
674
    fn form_data_parameters_become_one_object_schema() {
675
        let out = convert(json!({
676
            "swagger": "2.0",
677
            "paths": {"/upload": {"post": {
678
                "parameters": [
679
                    {"name": "caption", "in": "formData", "required": true,
680
                     "type": "string", "description": "what it shows"},
681
                    {"name": "photo", "in": "formData", "type": "file"}
682
                ],
683
                "responses": {}
684
            }}}
685
        }));
686
        let body = &out["paths"]["/upload"]["post"]["requestBody"];
687
        // A file forces multipart even though the spec listed no `consumes`.
688
        let schema = &body["content"]["multipart/form-data"]["schema"];
689
        assert_eq!(schema["type"], "object");
690
        assert_eq!(schema["required"], json!(["caption"]));
691
        assert_eq!(schema["properties"]["caption"]["type"], "string");
692
        assert_eq!(
693
            schema["properties"]["caption"]["description"],
694
            "what it shows"
695
        );
696
        // 2.0's `file` type is 3.0's binary string.
697
        assert_eq!(schema["properties"]["photo"]["type"], "string");
698
        assert_eq!(schema["properties"]["photo"]["format"], "binary");
699
    }
700
701
    #[test]
702
    fn form_without_a_file_defaults_to_urlencoded() {
703
        let out = convert(json!({
704
            "swagger": "2.0",
705
            "paths": {"/login": {"post": {
706
                "parameters": [{"name": "user", "in": "formData", "type": "string"}],
707
                "responses": {}
708
            }}}
709
        }));
710
        let content = &out["paths"]["/login"]["post"]["requestBody"]["content"];
711
        assert!(content.get("application/x-www-form-urlencoded").is_some());
712
    }
713
714
    #[test]
715
    fn inline_parameter_types_move_under_schema() {
716
        let out = convert(json!({
717
            "swagger": "2.0",
718
            "parameters": {"PetId": {
719
                "name": "petId", "in": "path", "required": true,
720
                "type": "integer", "format": "int64", "x-example": 123
721
            }},
722
            "paths": {"/pets": {"get": {
723
                "parameters": [{
724
                    "name": "tags", "in": "query", "type": "array",
725
                    "collectionFormat": "csv",
726
                    "items": {"type": "string", "enum": ["cat", "dog"]}
727
                }],
728
                "responses": {}
729
            }}}
730
        }));
731
        let shared = &out["components"]["parameters"]["PetId"];
732
        assert_eq!(
733
            shared["schema"],
734
            json!({"type": "integer", "format": "int64"})
735
        );
736
        assert_eq!(shared["in"], "path");
737
        // `x-example` is 2.0's only way to give a non-body parameter an example.
738
        assert_eq!(shared["example"], 123);
739
740
        let p = &out["paths"]["/pets"]["get"]["parameters"][0];
741
        assert_eq!(p["schema"]["type"], "array");
742
        assert_eq!(p["schema"]["items"]["enum"], json!(["cat", "dog"]));
743
        assert!(p.get("collectionFormat").is_none());
744
        assert!(p.get("type").is_none());
745
    }
746
747
    #[test]
748
    fn path_level_body_applies_to_each_operation() {
749
        let out = convert(json!({
750
            "swagger": "2.0",
751
            "paths": {"/pets": {
752
                "parameters": [
753
                    {"name": "pet", "in": "body", "schema": {"type": "object"}},
754
                    {"name": "petId", "in": "path", "required": true, "type": "string"}
755
                ],
756
                "put": {"responses": {}},
757
                "post": {
758
                    "parameters": [{"name": "own", "in": "body", "schema": {"type": "string"}}],
759
                    "responses": {}
760
                }
761
            }}
762
        }));
763
        let item = &out["paths"]["/pets"];
764
        // The path-level parameter list keeps only what 3.0 calls a parameter.
765
        assert_eq!(item["parameters"].as_array().unwrap().len(), 1);
766
        assert_eq!(item["parameters"][0]["schema"]["type"], "string");
767
        assert_eq!(
768
            item["put"]["requestBody"]["content"]["application/json"]["schema"]["type"],
769
            "object"
770
        );
771
        // An operation's own body wins over the inherited one.
772
        assert_eq!(
773
            item["post"]["requestBody"]["content"]["application/json"]["schema"]["type"],
774
            "string"
775
        );
776
    }
777
778
    #[test]
779
    fn security_definitions_become_security_schemes() {
780
        let out = convert(json!({
781
            "swagger": "2.0",
782
            "securityDefinitions": {
783
                "oauth": {
784
                    "type": "oauth2",
785
                    "flow": "application",
786
                    "tokenUrl": "https://auth.example.com/token",
787
                    "scopes": {"read": "Read"}
788
                },
789
                "code": {
790
                    "type": "oauth2",
791
                    "flow": "accessCode",
792
                    "authorizationUrl": "https://auth.example.com/authorize",
793
                    "tokenUrl": "https://auth.example.com/token"
794
                },
795
                "basic": {"type": "basic"},
796
                "key": {"type": "apiKey", "name": "X-Api-Key", "in": "header"}
797
            }
798
        }));
799
        let schemes = &out["components"]["securitySchemes"];
800
        assert_eq!(
801
            schemes["oauth"]["flows"]["clientCredentials"]["tokenUrl"],
802
            "https://auth.example.com/token"
803
        );
804
        assert_eq!(
805
            schemes["oauth"]["flows"]["clientCredentials"]["scopes"]["read"],
806
            "Read"
807
        );
808
        assert!(schemes["oauth"].get("flow").is_none());
809
        assert_eq!(
810
            schemes["code"]["flows"]["authorizationCode"]["authorizationUrl"],
811
            "https://auth.example.com/authorize"
812
        );
813
        // A flow with no declared scopes still gets the map 3.0 requires.
814
        assert_eq!(
815
            schemes["code"]["flows"]["authorizationCode"]["scopes"],
816
            json!({})
817
        );
818
        assert_eq!(schemes["basic"], json!({"type": "http", "scheme": "basic"}));
819
        assert_eq!(
820
            schemes["key"],
821
            json!({"type": "apiKey", "name": "X-Api-Key", "in": "header"})
822
        );
823
    }
824
825
    #[test]
826
    fn response_schemas_and_examples_move_under_content() {
827
        let out = convert(json!({
828
            "swagger": "2.0",
829
            "produces": ["application/json"],
830
            "paths": {"/pets": {"get": {
831
                "responses": {
832
                    "200": {
833
                        "description": "ok",
834
                        "schema": {"type": "array", "items": {"type": "string"}},
835
                        "examples": {"application/json": ["fido"]}
836
                    },
837
                    "204": {"description": "empty"}
838
                }
839
            }}}
840
        }));
841
        let responses = &out["paths"]["/pets"]["get"]["responses"];
842
        let media = &responses["200"]["content"]["application/json"];
843
        assert_eq!(media["schema"]["type"], "array");
844
        assert_eq!(media["example"], json!(["fido"]));
845
        assert_eq!(responses["200"]["description"], "ok");
846
        // Nothing to describe means no `content` at all.
847
        assert!(responses["204"].get("content").is_none());
848
    }
849
850
    #[test]
851
    fn operation_media_types_override_the_document() {
852
        let out = convert(json!({
853
            "swagger": "2.0",
854
            "consumes": ["application/json"],
855
            "produces": ["application/json"],
856
            "paths": {"/pets": {"post": {
857
                "consumes": ["text/plain"],
858
                "produces": ["text/plain"],
859
                "parameters": [{"name": "b", "in": "body", "schema": {"type": "string"}}],
860
                "responses": {"200": {"description": "ok", "schema": {"type": "string"}}}
861
            }}}
862
        }));
863
        let op = &out["paths"]["/pets"]["post"];
864
        assert!(op["requestBody"]["content"].get("text/plain").is_some());
865
        assert!(
866
            op["requestBody"]["content"]
867
                .get("application/json")
868
                .is_none()
869
        );
870
        assert!(
871
            op["responses"]["200"]["content"]
872
                .get("text/plain")
873
                .is_some()
874
        );
875
        assert!(op.get("consumes").is_none());
876
    }
877
878
    #[test]
879
    fn response_headers_get_schemas() {
880
        let out = convert(json!({
881
            "swagger": "2.0",
882
            "paths": {"/pets": {"get": {"responses": {"200": {
883
                "description": "ok",
884
                "headers": {"X-Rate-Limit": {"type": "integer", "description": "calls left"}}
885
            }}}}}
886
        }));
887
        let header = &out["paths"]["/pets"]["get"]["responses"]["200"]["headers"]["X-Rate-Limit"];
888
        assert_eq!(header["schema"], json!({"type": "integer"}));
889
        assert_eq!(header["description"], "calls left");
890
    }
891
892
    #[test]
893
    fn unrecognised_fields_are_carried_across() {
894
        let out = convert(json!({
895
            "swagger": "2.0",
896
            "info": {"title": "t", "version": "1"},
897
            "tags": [{"name": "pets"}],
898
            "security": [{"key": []}],
899
            "x-logo": {"url": "https://example.com/logo.png"}
900
        }));
901
        assert_eq!(out["openapi"], "3.0.0");
902
        assert!(out.get("swagger").is_none());
903
        assert_eq!(out["info"]["title"], "t");
904
        assert_eq!(out["tags"][0]["name"], "pets");
905
        assert_eq!(out["security"], json!([{"key": []}]));
906
        assert_eq!(out["x-logo"]["url"], "https://example.com/logo.png");
907
    }
908
}