feat: initial OpenAPI 2.0 suport be0b0d1a
Steve · 2026-09-02 11:27 9 file(s) · +1198 −14
AGENTS.md +20 −1
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.
README.md +2 −2
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
src/main.rs +5 −2
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
}
src/openapi/loader.rs +23 −6
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]
src/openapi/mod.rs +4 −1
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};
src/openapi/swagger2.rs (added) +908 −0
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 +
}
tests/fixtures/petstore20.json (added) +131 −0
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 +
}
tests/import_tests.rs +97 −0
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 +
}
tests/ui_tests.rs +8 −2
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
}