Merge pull request #1 from stevedylandev/feat/openapi-2-support
0c9af2c8
9 file(s) · +1198 −14
| 16 | 16 | ├── store.rs # ~/.config/cielago persistence, AppConfig |
|
| 17 | 17 | ├── openapi/ |
|
| 18 | 18 | │ ├── loader.rs # load spec from file path or http(s) URL, JSON/YAML |
|
| 19 | + | │ ├── swagger2.rs # Swagger 2.0 -> OpenAPI 3.0 rewrite, applied while parsing |
|
| 19 | 20 | │ ├── resolve.rs # local `#/...` $ref resolution (cycle-safe) |
|
| 20 | 21 | │ ├── examples.rs # schema -> example JSON value generation |
|
| 21 | 22 | │ ├── docs.rs # schema -> FieldDoc (types, enums) for the Docs tab |
|
| 42 | 43 | onto `~/.config/cielago` on first use (see `LEGACY_DIR_NAMES`), so existing |
|
| 43 | 44 | collections survive. Drop those migrations once they've had time to run |
|
| 44 | 45 | everywhere. |
|
| 46 | + | - **Swagger 2.0 is normalised, not supported twice.** `openapi::swagger2` |
|
| 47 | + | rewrites a 2.0 document into the 3.0 shape inside `loader::parse_spec`, so |
|
| 48 | + | `import`/`examples`/`docs` only ever see 3.x — adding a dialect to those |
|
| 49 | + | would have meant a second branch in every schema walk. The rewrite moves |
|
| 50 | + | `definitions`/`parameters`/`responses`/`securityDefinitions` under |
|
| 51 | + | `components` (rewriting every local `$ref` to match), folds |
|
| 52 | + | `schemes`+`host`+`basePath` into `servers`, lifts a parameter's inline |
|
| 53 | + | `type`/`format`/… into a `schema`, and turns `in: body` / `in: formData` |
|
| 54 | + | parameters into a `requestBody` keyed by the operation's `consumes`. |
|
| 55 | + | Unrecognised keys are carried across untouched, so `x-` extensions survive. |
|
| 56 | + | Deliberately lossy: `collectionFormat` and operation-level `schemes` are |
|
| 57 | + | dropped (3.0 has no per-operation server, and cielago comma-joins array |
|
| 58 | + | params either way), and `https` is ordered ahead of `http` so a spec offering |
|
| 59 | + | both opens on the secure one. |
|
| 45 | 60 | - **No remote `$ref`s.** `openapi::resolve` only follows local |
|
| 46 | 61 | `#/components/...` JSON pointers. Specs that split across files aren't |
|
| 47 | 62 | supported — bundle them first if you hit this. |
|
| 159 | 174 | ||
| 160 | 175 | ## Known gaps (intentionally out of scope for v1) |
|
| 161 | 176 | ||
| 162 | - | Swagger 2.0, interactive OAuth flows (auth-code / device / implicit — only |
|
| 177 | + | Interactive OAuth flows (auth-code / device / implicit — only |
|
| 163 | 178 | client-credentials is automated; bearer and API-key are static), |
|
| 164 | 179 | collection folders beyond tag grouping, request history/response diffing. |
|
| 180 | + | A form body (2.0 `formData`, or any `x-www-form-urlencoded`/`multipart` |
|
| 181 | + | `requestBody`) is imported as the JSON object that describes it, with the |
|
| 182 | + | matching `Content-Type` — the fields are all there and documented, but the |
|
| 183 | + | body isn't encoded as a form at send time. |
|
| 165 | 184 | The Docs tab covers request inputs only — response schemas and status codes |
|
| 166 | 185 | aren't imported. |
|
| 10 | 10 | ||
| 11 | 11 | ## Features |
|
| 12 | 12 | ||
| 13 | - | - **OpenAPI 3.x import** — turn a spec (file or URL) into a collection with requests, params, example bodies, servers, and docs prefilled. Import walks you through auth (bearer / api key / oauth2), picking an active server, and display preferences; pass `-y` to skip it. |
|
| 13 | + | - **OpenAPI import** — turn a spec (file or URL) into a collection with requests, params, example bodies, servers, and docs prefilled. Swagger 2.0, OpenAPI 3.0 and 3.1 are all accepted; 2.0 specs are converted on the way in. Import walks you through auth (bearer / api key / oauth2), picking an active server, and display preferences; pass `-y` to skip it. |
|
| 14 | 14 | - **Ad-hoc collections** — no spec needed; paste a full URL and it's split into server, path, and query params for you. |
|
| 15 | 15 | - **Vim-style TUI** — three panes (requests / editor / response), `j`/`k` navigation, `:` command line, `/` incremental search. |
|
| 16 | 16 | - **Variables** — `{{name}}` from the collection, plus dynamic ones like `{{uuid}}`, `{{timestamp}}`, `{{randomInt(1,100)}}`. |
|
| 60 | 60 | ```sh |
|
| 61 | 61 | cielago # open the last-used collection in the TUI |
|
| 62 | 62 | cielago open [name] # open a specific collection |
|
| 63 | - | cielago import <spec|url> [-y] # import an OpenAPI 3.x spec (-y skips setup) |
|
| 63 | + | cielago import <spec|url> [-y] # import an OpenAPI 2.0/3.x spec (-y skips setup) |
|
| 64 | 64 | cielago new <name> [-s u] [-y] # create a collection (walkthrough; -y skips) |
|
| 65 | 65 | cielago list [-l] # list collections (-l adds counts + paths) |
|
| 66 | 66 | cielago info <name> # servers, counts, auth, groups |
|
| 39 | 39 | ||
| 40 | 40 | #[derive(Subcommand)] |
|
| 41 | 41 | enum Command { |
|
| 42 | - | /// Import an OpenAPI 3.x spec (JSON/YAML, file path or URL) as a collection |
|
| 42 | + | /// Import an OpenAPI 2.0/3.x spec (JSON/YAML, file path or URL) as a collection |
|
| 43 | 43 | Import { |
|
| 44 | 44 | /// File path or http(s) URL of the spec |
|
| 45 | 45 | source: String, |
|
| 189 | 189 | } else { |
|
| 190 | 190 | "" |
|
| 191 | 191 | }; |
|
| 192 | - | format!("oauth2 client-credentials, token url {}{state}", auth.token_url) |
|
| 192 | + | format!( |
|
| 193 | + | "oauth2 client-credentials, token url {}{state}", |
|
| 194 | + | auth.token_url |
|
| 195 | + | ) |
|
| 193 | 196 | } |
|
| 194 | 197 | } |
|
| 195 | 198 | } |
|
| 1 | 1 | use anyhow::{Context, Result}; |
|
| 2 | 2 | use serde_json::Value; |
|
| 3 | 3 | ||
| 4 | + | use super::swagger2; |
|
| 5 | + | ||
| 4 | 6 | /// Load a spec from a local file path or an http(s) URL. |
|
| 5 | 7 | pub async fn load_spec(source: &str) -> Result<Value> { |
|
| 6 | 8 | if source.starts_with("http://") || source.starts_with("https://") { |
|
| 22 | 24 | } |
|
| 23 | 25 | } |
|
| 24 | 26 | ||
| 25 | - | /// Parse spec text as JSON, falling back to YAML. |
|
| 27 | + | /// Parse spec text as JSON, falling back to YAML. A Swagger 2.0 document is |
|
| 28 | + | /// converted to its OpenAPI 3.0 equivalent here, so every caller downstream |
|
| 29 | + | /// only has to understand one dialect. |
|
| 26 | 30 | pub fn parse_spec(text: &str) -> Result<Value> { |
|
| 27 | - | if let Ok(v) = serde_json::from_str::<Value>(text) { |
|
| 28 | - | return Ok(v); |
|
| 29 | - | } |
|
| 30 | - | let v: Value = serde_yaml::from_str(text).context("spec is neither valid JSON nor YAML")?; |
|
| 31 | - | Ok(v) |
|
| 31 | + | let v = match serde_json::from_str::<Value>(text) { |
|
| 32 | + | Ok(v) => v, |
|
| 33 | + | Err(_) => serde_yaml::from_str(text).context("spec is neither valid JSON nor YAML")?, |
|
| 34 | + | }; |
|
| 35 | + | Ok(if swagger2::is_swagger2(&v) { |
|
| 36 | + | swagger2::to_openapi3(v) |
|
| 37 | + | } else { |
|
| 38 | + | v |
|
| 39 | + | }) |
|
| 32 | 40 | } |
|
| 33 | 41 | ||
| 34 | 42 | #[cfg(test)] |
|
| 44 | 52 | let v = parse_spec(yaml).unwrap(); |
|
| 45 | 53 | assert_eq!(v["openapi"], "3.1.0"); |
|
| 46 | 54 | assert_eq!(v["info"]["title"], "t"); |
|
| 55 | + | } |
|
| 56 | + | ||
| 57 | + | #[test] |
|
| 58 | + | fn converts_swagger_2_on_parse() { |
|
| 59 | + | let yaml = "swagger: '2.0'\nhost: api.example.com\nbasePath: /v1\nschemes: [https]\n"; |
|
| 60 | + | let v = parse_spec(yaml).unwrap(); |
|
| 61 | + | assert_eq!(v["openapi"], "3.0.0"); |
|
| 62 | + | assert!(v.get("swagger").is_none()); |
|
| 63 | + | assert_eq!(v["servers"][0]["url"], "https://api.example.com/v1"); |
|
| 47 | 64 | } |
|
| 48 | 65 | ||
| 49 | 66 | #[test] |
|
| 1 | - | //! OpenAPI 3.x (3.0 + 3.1, JSON/YAML) loading and conversion into collections. |
|
| 1 | + | //! OpenAPI (2.0 + 3.0 + 3.1, JSON/YAML) loading and conversion into collections. |
|
| 2 | 2 | //! |
|
| 3 | 3 | //! Specs are parsed into [`serde_json::Value`] rather than a strict spec model |
|
| 4 | 4 | //! so that unknown fields and 3.0/3.1 differences are tolerated gracefully. |
|
| 5 | + | //! Swagger 2.0 documents are rewritten into the 3.0 shape by [`swagger2`] while |
|
| 6 | + | //! loading, so nothing below [`loader`] deals with more than one dialect. |
|
| 5 | 7 | ||
| 6 | 8 | pub mod docs; |
|
| 7 | 9 | pub mod examples; |
|
| 8 | 10 | pub mod import; |
|
| 9 | 11 | pub mod loader; |
|
| 10 | 12 | pub mod resolve; |
|
| 13 | + | pub mod swagger2; |
|
| 11 | 14 | ||
| 12 | 15 | pub use import::import_spec; |
|
| 13 | 16 | pub use loader::{load_spec, parse_spec}; |
| 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 | + | } |
| 1 | + | { |
|
| 2 | + | "swagger": "2.0", |
|
| 3 | + | "info": { "title": "Pet Store 2.0", "version": "1.0.0" }, |
|
| 4 | + | "host": "api.pets.example.com", |
|
| 5 | + | "basePath": "/v2", |
|
| 6 | + | "schemes": ["http", "https"], |
|
| 7 | + | "consumes": ["application/json"], |
|
| 8 | + | "produces": ["application/json"], |
|
| 9 | + | "securityDefinitions": { |
|
| 10 | + | "petstore_auth": { |
|
| 11 | + | "type": "oauth2", |
|
| 12 | + | "flow": "application", |
|
| 13 | + | "tokenUrl": "https://auth.pets.example.com/oauth/token", |
|
| 14 | + | "scopes": { |
|
| 15 | + | "read:pets": "Read pets", |
|
| 16 | + | "write:pets": "Modify pets" |
|
| 17 | + | } |
|
| 18 | + | }, |
|
| 19 | + | "api_key": { |
|
| 20 | + | "type": "apiKey", |
|
| 21 | + | "name": "X-Api-Key", |
|
| 22 | + | "in": "header" |
|
| 23 | + | } |
|
| 24 | + | }, |
|
| 25 | + | "security": [{ "api_key": [] }], |
|
| 26 | + | "parameters": { |
|
| 27 | + | "PetId": { |
|
| 28 | + | "name": "petId", |
|
| 29 | + | "in": "path", |
|
| 30 | + | "required": true, |
|
| 31 | + | "type": "integer", |
|
| 32 | + | "format": "int64", |
|
| 33 | + | "x-example": 123 |
|
| 34 | + | } |
|
| 35 | + | }, |
|
| 36 | + | "definitions": { |
|
| 37 | + | "Pet": { |
|
| 38 | + | "type": "object", |
|
| 39 | + | "required": ["name"], |
|
| 40 | + | "properties": { |
|
| 41 | + | "id": { "type": "integer", "format": "int64" }, |
|
| 42 | + | "name": { "type": "string" }, |
|
| 43 | + | "tag": { "type": "string", "default": "friendly" }, |
|
| 44 | + | "status": { "type": "string", "enum": ["available", "pending", "sold"] } |
|
| 45 | + | } |
|
| 46 | + | }, |
|
| 47 | + | "Error": { |
|
| 48 | + | "type": "object", |
|
| 49 | + | "properties": { "message": { "type": "string" } } |
|
| 50 | + | } |
|
| 51 | + | }, |
|
| 52 | + | "paths": { |
|
| 53 | + | "/pets": { |
|
| 54 | + | "get": { |
|
| 55 | + | "operationId": "listPets", |
|
| 56 | + | "tags": ["pets"], |
|
| 57 | + | "description": "Lists pets, newest first.", |
|
| 58 | + | "parameters": [ |
|
| 59 | + | { |
|
| 60 | + | "name": "limit", |
|
| 61 | + | "in": "query", |
|
| 62 | + | "required": false, |
|
| 63 | + | "type": "integer", |
|
| 64 | + | "default": 20, |
|
| 65 | + | "description": "How many pets to return." |
|
| 66 | + | }, |
|
| 67 | + | { |
|
| 68 | + | "name": "tags", |
|
| 69 | + | "in": "query", |
|
| 70 | + | "required": false, |
|
| 71 | + | "type": "array", |
|
| 72 | + | "collectionFormat": "csv", |
|
| 73 | + | "items": { "type": "string", "enum": ["cat", "dog"] } |
|
| 74 | + | }, |
|
| 75 | + | { |
|
| 76 | + | "name": "X-Tenant-Id", |
|
| 77 | + | "in": "header", |
|
| 78 | + | "required": true, |
|
| 79 | + | "type": "string", |
|
| 80 | + | "x-example": "acme" |
|
| 81 | + | } |
|
| 82 | + | ], |
|
| 83 | + | "responses": { |
|
| 84 | + | "200": { |
|
| 85 | + | "description": "a list of pets", |
|
| 86 | + | "schema": { "type": "array", "items": { "$ref": "#/definitions/Pet" } } |
|
| 87 | + | }, |
|
| 88 | + | "default": { |
|
| 89 | + | "description": "error", |
|
| 90 | + | "schema": { "$ref": "#/definitions/Error" } |
|
| 91 | + | } |
|
| 92 | + | } |
|
| 93 | + | }, |
|
| 94 | + | "post": { |
|
| 95 | + | "operationId": "createPet", |
|
| 96 | + | "tags": ["pets"], |
|
| 97 | + | "parameters": [ |
|
| 98 | + | { |
|
| 99 | + | "name": "body", |
|
| 100 | + | "in": "body", |
|
| 101 | + | "required": true, |
|
| 102 | + | "schema": { "$ref": "#/definitions/Pet" } |
|
| 103 | + | } |
|
| 104 | + | ], |
|
| 105 | + | "responses": { |
|
| 106 | + | "201": { "description": "created", "schema": { "$ref": "#/definitions/Pet" } } |
|
| 107 | + | } |
|
| 108 | + | } |
|
| 109 | + | }, |
|
| 110 | + | "/pets/{petId}": { |
|
| 111 | + | "parameters": [{ "$ref": "#/parameters/PetId" }], |
|
| 112 | + | "get": { |
|
| 113 | + | "operationId": "getPet", |
|
| 114 | + | "tags": ["pets"], |
|
| 115 | + | "responses": { |
|
| 116 | + | "200": { "description": "a pet", "schema": { "$ref": "#/definitions/Pet" } } |
|
| 117 | + | } |
|
| 118 | + | }, |
|
| 119 | + | "post": { |
|
| 120 | + | "operationId": "uploadPetPhoto", |
|
| 121 | + | "tags": ["pets"], |
|
| 122 | + | "consumes": ["multipart/form-data"], |
|
| 123 | + | "parameters": [ |
|
| 124 | + | { "name": "caption", "in": "formData", "required": true, "type": "string" }, |
|
| 125 | + | { "name": "photo", "in": "formData", "required": false, "type": "file" } |
|
| 126 | + | ], |
|
| 127 | + | "responses": { "200": { "description": "uploaded" } } |
|
| 128 | + | } |
|
| 129 | + | } |
|
| 130 | + | } |
|
| 131 | + | } |
| 283 | 283 | assert!(!map.contains_key("b")); |
|
| 284 | 284 | assert!(!map.contains_key("")); |
|
| 285 | 285 | } |
|
| 286 | + | ||
| 287 | + | #[tokio::test] |
|
| 288 | + | async fn imports_swagger_20() { |
|
| 289 | + | let c = import_fixture("petstore20.json", "pets 2.0").await; |
|
| 290 | + | ||
| 291 | + | // host + basePath + schemes become servers, https first. |
|
| 292 | + | assert_eq!( |
|
| 293 | + | c.servers, |
|
| 294 | + | vec![ |
|
| 295 | + | "https://api.pets.example.com/v2".to_string(), |
|
| 296 | + | "http://api.pets.example.com/v2".to_string() |
|
| 297 | + | ] |
|
| 298 | + | ); |
|
| 299 | + | ||
| 300 | + | // `flow: application` is the client-credentials flow. |
|
| 301 | + | let auth = c.auth.as_ref().expect("auth should be prefilled"); |
|
| 302 | + | assert_eq!(auth.token_url, "https://auth.pets.example.com/oauth/token"); |
|
| 303 | + | assert_eq!(auth.scopes, vec!["read:pets", "write:pets"]); |
|
| 304 | + | ||
| 305 | + | assert_eq!(c.requests.len(), 4); |
|
| 306 | + | ||
| 307 | + | let list = c.requests.iter().find(|r| r.name == "listPets").unwrap(); |
|
| 308 | + | assert_eq!(list.method, Method::Get); |
|
| 309 | + | assert_eq!(list.path, "/pets"); |
|
| 310 | + | assert_eq!(list.tags, vec!["pets"]); |
|
| 311 | + | // Inline `type`/`default` still reach the params they describe. |
|
| 312 | + | let limit = list.query.iter().find(|q| q.key == "limit").unwrap(); |
|
| 313 | + | assert!(!limit.enabled); |
|
| 314 | + | assert_eq!(limit.value, "20"); |
|
| 315 | + | let tenant = list |
|
| 316 | + | .headers |
|
| 317 | + | .iter() |
|
| 318 | + | .find(|h| h.key == "X-Tenant-Id") |
|
| 319 | + | .unwrap(); |
|
| 320 | + | assert!(tenant.enabled); |
|
| 321 | + | assert_eq!(tenant.value, "acme"); |
|
| 322 | + | // `produces` becomes the Accept header, the apiKey scheme its own row. |
|
| 323 | + | let accept = list.headers.iter().find(|h| h.key == "Accept").unwrap(); |
|
| 324 | + | assert_eq!(accept.value, "application/json"); |
|
| 325 | + | let key = list.headers.iter().find(|h| h.key == "X-Api-Key").unwrap(); |
|
| 326 | + | assert!(!key.enabled); |
|
| 327 | + | // Item enums off an array param land in the docs. |
|
| 328 | + | let tags = list.docs.iter().find(|d| d.name == "tags").unwrap(); |
|
| 329 | + | assert_eq!(tags.ty, "array<string>"); |
|
| 330 | + | assert_eq!(tags.options, ["cat", "dog"]); |
|
| 331 | + | // The 200 response schema is documented too. |
|
| 332 | + | assert!(list.docs.iter().any(|d| d.location == "response")); |
|
| 333 | + | ||
| 334 | + | // `in: body` becomes the request body, generated from the definition. |
|
| 335 | + | let create = c.requests.iter().find(|r| r.name == "createPet").unwrap(); |
|
| 336 | + | assert_eq!(create.method, Method::Post); |
|
| 337 | + | let body = create.body.as_deref().unwrap(); |
|
| 338 | + | assert!(body.contains("\"name\": \"string\""), "body was: {body}"); |
|
| 339 | + | assert!(body.contains("\"tag\": \"friendly\""), "body was: {body}"); |
|
| 340 | + | let content_type = create |
|
| 341 | + | .headers |
|
| 342 | + | .iter() |
|
| 343 | + | .find(|h| h.key == "Content-Type") |
|
| 344 | + | .unwrap(); |
|
| 345 | + | assert_eq!(content_type.value, "application/json"); |
|
| 346 | + | assert!( |
|
| 347 | + | create.docs.iter().any(|d| d.name == "name" && d.required), |
|
| 348 | + | "body fields should be documented" |
|
| 349 | + | ); |
|
| 350 | + | ||
| 351 | + | // A shared `#/parameters/…` reference resolves, x-example included. |
|
| 352 | + | let get_pet = c.requests.iter().find(|r| r.name == "getPet").unwrap(); |
|
| 353 | + | assert_eq!(get_pet.path, "/pets/{petId}"); |
|
| 354 | + | let pet_id = get_pet |
|
| 355 | + | .path_params |
|
| 356 | + | .iter() |
|
| 357 | + | .find(|p| p.key == "petId") |
|
| 358 | + | .unwrap(); |
|
| 359 | + | assert!(pet_id.enabled); |
|
| 360 | + | assert_eq!(pet_id.value, "123"); |
|
| 361 | + | ||
| 362 | + | // formData parameters collapse into one multipart body. |
|
| 363 | + | let upload = c |
|
| 364 | + | .requests |
|
| 365 | + | .iter() |
|
| 366 | + | .find(|r| r.name == "uploadPetPhoto") |
|
| 367 | + | .unwrap(); |
|
| 368 | + | let content_type = upload |
|
| 369 | + | .headers |
|
| 370 | + | .iter() |
|
| 371 | + | .find(|h| h.key == "Content-Type") |
|
| 372 | + | .unwrap(); |
|
| 373 | + | assert_eq!(content_type.value, "multipart/form-data"); |
|
| 374 | + | let body = upload.body.as_deref().unwrap(); |
|
| 375 | + | assert!(body.contains("\"caption\""), "body was: {body}"); |
|
| 376 | + | assert!(body.contains("\"photo\""), "body was: {body}"); |
|
| 377 | + | let caption = upload.docs.iter().find(|d| d.name == "caption").unwrap(); |
|
| 378 | + | assert!(caption.required); |
|
| 379 | + | assert_eq!(caption.location, "body"); |
|
| 380 | + | // The path-level parameter still applies to the operation. |
|
| 381 | + | assert!(upload.path_params.iter().any(|p| p.key == "petId")); |
|
| 382 | + | } |
| 243 | 243 | app.commit_edit(); |
|
| 244 | 244 | let buf = render(&mut app, 100, 40); |
|
| 245 | 245 | let s = screen(&buf); |
|
| 246 | - | let line = s.lines().find(|l| l.contains("url (verb path)")).unwrap_or("<none>"); |
|
| 246 | + | let line = s |
|
| 247 | + | .lines() |
|
| 248 | + | .find(|l| l.contains("url (verb path)")) |
|
| 249 | + | .unwrap_or("<none>"); |
|
| 247 | 250 | println!("PROMPT LINE: {:?}", line); |
|
| 248 | - | assert!(s.contains("url (verb path)> GET"), "screen missing GET prefill"); |
|
| 251 | + | assert!( |
|
| 252 | + | s.contains("url (verb path)> GET"), |
|
| 253 | + | "screen missing GET prefill" |
|
| 254 | + | ); |
|
| 249 | 255 | } |