| 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 | } |