src/openapi/import.rs 12.1 K raw
1
//! Conversion of a parsed OpenAPI document into a cielago [`Collection`].
2
3
use std::collections::HashSet;
4
5
use serde_json::Value;
6
7
use super::docs::{body_docs, param_doc};
8
use super::examples::example_for_schema;
9
use super::resolve::deref;
10
use crate::model::{AuthStyle, Collection, KeyValueRow, Method, OAuthConfig, SavedRequest};
11
12
const METHODS: [&str; 7] = ["get", "post", "put", "patch", "delete", "head", "options"];
13
14
pub fn import_spec(doc: &Value, name: &str, source: Option<String>) -> Collection {
15
    let mut collection = Collection::new(name);
16
    collection.spec_source = source;
17
18
    if let Some(servers) = doc.get("servers").and_then(Value::as_array) {
19
        for s in servers {
20
            if let Some(url) = s.get("url").and_then(Value::as_str) {
21
                let url = url.trim_end_matches('/').to_string();
22
                if !url.is_empty() && !collection.servers.contains(&url) {
23
                    collection.servers.push(url);
24
                }
25
            }
26
        }
27
    }
28
29
    collection.auth = extract_oauth(doc);
30
31
    if let Some(paths) = doc.get("paths").and_then(Value::as_object) {
32
        for (path, item) in paths {
33
            let item = deref(doc, item);
34
            let path_level_params = item.get("parameters").and_then(Value::as_array);
35
            for method in METHODS {
36
                let Some(op) = item.get(method) else { continue };
37
                collection
38
                    .requests
39
                    .push(build_request(doc, path, method, path_level_params, op));
40
            }
41
        }
42
    }
43
44
    collection
45
}
46
47
/// Find the first `oauth2` security scheme with a clientCredentials flow and
48
/// prefill token URL + scopes (credentials are filled in by the user).
49
fn extract_oauth(doc: &Value) -> Option<OAuthConfig> {
50
    let schemes = doc.get("components")?.get("securitySchemes")?.as_object()?;
51
    for (_, scheme) in schemes {
52
        let scheme = deref(doc, scheme);
53
        if scheme.get("type").and_then(Value::as_str) != Some("oauth2") {
54
            continue;
55
        }
56
        let Some(flow) = scheme.get("flows").and_then(|f| f.get("clientCredentials")) else {
57
            continue;
58
        };
59
        let token_url = flow
60
            .get("tokenUrl")
61
            .and_then(Value::as_str)
62
            .unwrap_or_default()
63
            .to_string();
64
        let scopes = flow
65
            .get("scopes")
66
            .and_then(Value::as_object)
67
            .map(|o| o.keys().cloned().collect())
68
            .unwrap_or_default();
69
        return Some(OAuthConfig {
70
            token_url,
71
            scopes,
72
            auth_style: AuthStyle::Basic,
73
            ..Default::default()
74
        });
75
    }
76
    None
77
}
78
79
fn build_request(
80
    doc: &Value,
81
    path: &str,
82
    method: &str,
83
    path_level_params: Option<&Vec<Value>>,
84
    op: &Value,
85
) -> SavedRequest {
86
    let summary = op
87
        .get("summary")
88
        .and_then(Value::as_str)
89
        .filter(|s| !s.trim().is_empty())
90
        .map(str::to_string);
91
    let operation_id = op
92
        .get("operationId")
93
        .and_then(Value::as_str)
94
        .filter(|s| !s.trim().is_empty())
95
        .map(str::to_string);
96
97
    // `summary` first: it's the human-readable descriptor. `operationId` is
98
    // often a long generated controller name.
99
    let name = summary
100
        .clone()
101
        .or_else(|| operation_id.clone())
102
        .unwrap_or_else(|| format!("{} {}", method.to_uppercase(), path));
103
104
    let mut req = SavedRequest::blank(name);
105
    req.summary = summary;
106
    req.operation_id = operation_id;
107
    req.description = op
108
        .get("description")
109
        .and_then(Value::as_str)
110
        .map(str::trim)
111
        .filter(|s| !s.is_empty())
112
        .map(str::to_string);
113
    req.method = Method::parse(method).unwrap_or(Method::Get);
114
    req.path = path.to_string();
115
    req.tags = op
116
        .get("tags")
117
        .and_then(Value::as_array)
118
        .map(|a| {
119
            a.iter()
120
                .filter_map(Value::as_str)
121
                .map(String::from)
122
                .collect()
123
        })
124
        .unwrap_or_default();
125
126
    // Merge operation-level and path-level parameters; operation wins on
127
    // duplicate (name, in) pairs.
128
    let mut merged: Vec<&Value> = Vec::new();
129
    let mut seen: HashSet<(String, String)> = HashSet::new();
130
131
    let op_params = op.get("parameters").and_then(Value::as_array);
132
    for p in op_params.into_iter().flatten() {
133
        let p = deref(doc, p);
134
        let key = param_key(p);
135
        seen.insert(key);
136
        merged.push(p);
137
    }
138
    for p in path_level_params.into_iter().flatten() {
139
        let p = deref(doc, p);
140
        if seen.insert(param_key(p)) {
141
            merged.push(p);
142
        }
143
    }
144
145
    for p in merged {
146
        let name = p.get("name").and_then(Value::as_str).unwrap_or_default();
147
        if name.is_empty() {
148
            continue;
149
        }
150
        let required = p.get("required").and_then(Value::as_bool).unwrap_or(false);
151
        let location = p.get("in").and_then(Value::as_str).unwrap_or("");
152
        let value = param_value(doc, p, required);
153
        match location {
154
            "path" => req.path_params.push(KeyValueRow::new(name, value, true)),
155
            "query" => req.query.push(KeyValueRow::new(name, value, required)),
156
            "header" => req.headers.push(KeyValueRow::new(name, value, required)),
157
            _ => continue, // cookies etc. unsupported in v1
158
        }
159
        req.docs.push(param_doc(doc, p));
160
    }
161
162
    // Headers the spec implies without listing them as `in: header` params.
163
    // Explicit params win on a name collision.
164
    for row in implied_headers(doc, op) {
165
        if !req
166
            .headers
167
            .iter()
168
            .any(|h| h.key.eq_ignore_ascii_case(&row.key))
169
        {
170
            req.headers.push(row);
171
        }
172
    }
173
174
    req.body = extract_body(doc, op);
175
    if let Some(schema) = body_media(doc, op).and_then(|m| m.get("schema")) {
176
        req.docs.extend(body_docs(doc, schema));
177
    }
178
    req
179
}
180
181
/// Headers an operation carries by definition rather than by parameter: the
182
/// media type it consumes, the one it produces, and any apiKey-in-header
183
/// security scheme it requires. API-key rows arrive disabled — the value is
184
/// the user's to supply.
185
fn implied_headers(doc: &Value, op: &Value) -> Vec<KeyValueRow> {
186
    let mut out = Vec::new();
187
    if let Some(ct) = request_media_type(doc, op) {
188
        out.push(KeyValueRow::new("Content-Type", ct, true));
189
    }
190
    if let Some(accept) = response_media_type(doc, op) {
191
        out.push(KeyValueRow::new("Accept", accept, true));
192
    }
193
    for name in api_key_headers(doc, op) {
194
        out.push(KeyValueRow::new(name, "", false));
195
    }
196
    out
197
}
198
199
fn request_media_type(doc: &Value, op: &Value) -> Option<String> {
200
    let rb = deref(doc, op.get("requestBody")?);
201
    let content = rb.get("content").and_then(Value::as_object)?;
202
    pick_media(content).map(|(k, _)| k.clone())
203
}
204
205
/// Media type from the first success response (or `default`), so `Accept`
206
/// matches what the endpoint actually returns.
207
fn response_media_type(doc: &Value, op: &Value) -> Option<String> {
208
    let responses = op.get("responses").and_then(Value::as_object)?;
209
    let resp = responses
210
        .iter()
211
        .find(|(code, _)| code.starts_with('2'))
212
        .or_else(|| {
213
            responses
214
                .iter()
215
                .find(|(code, _)| code.as_str() == "default")
216
        })
217
        .map(|(_, v)| v)?;
218
    let content = deref(doc, resp).get("content").and_then(Value::as_object)?;
219
    pick_media(content).map(|(k, _)| k.clone())
220
}
221
222
/// Header names from apiKey security schemes this operation requires,
223
/// preferring operation-level `security` over the document default.
224
fn api_key_headers(doc: &Value, op: &Value) -> Vec<String> {
225
    let Some(requirements) = op
226
        .get("security")
227
        .or_else(|| doc.get("security"))
228
        .and_then(Value::as_array)
229
    else {
230
        return Vec::new();
231
    };
232
    let Some(schemes) = doc
233
        .get("components")
234
        .and_then(|c| c.get("securitySchemes"))
235
        .and_then(Value::as_object)
236
    else {
237
        return Vec::new();
238
    };
239
240
    let mut out: Vec<String> = Vec::new();
241
    for requirement in requirements {
242
        let Some(obj) = requirement.as_object() else {
243
            continue;
244
        };
245
        for scheme_name in obj.keys() {
246
            let Some(scheme) = schemes.get(scheme_name) else {
247
                continue;
248
            };
249
            let scheme = deref(doc, scheme);
250
            if scheme.get("type").and_then(Value::as_str) != Some("apiKey")
251
                || scheme.get("in").and_then(Value::as_str) != Some("header")
252
            {
253
                continue;
254
            }
255
            if let Some(name) = scheme.get("name").and_then(Value::as_str)
256
                && !name.is_empty()
257
                && !out.iter().any(|e| e.eq_ignore_ascii_case(name))
258
            {
259
                out.push(name.to_string());
260
            }
261
        }
262
    }
263
    out
264
}
265
266
fn param_key(p: &Value) -> (String, String) {
267
    (
268
        p.get("name")
269
            .and_then(Value::as_str)
270
            .unwrap_or_default()
271
            .into(),
272
        p.get("in")
273
            .and_then(Value::as_str)
274
            .unwrap_or_default()
275
            .into(),
276
    )
277
}
278
279
/// Value for a parameter. Required params fall back to type-based stubs so the
280
/// request is sendable out of the box; optional params only get explicitly
281
/// authored examples/defaults (otherwise empty).
282
fn param_value(doc: &Value, p: &Value, required: bool) -> String {
283
    if let Some(v) = explicit_param_value(doc, p) {
284
        return v;
285
    }
286
    if !required {
287
        return String::new();
288
    }
289
    match p.get("schema") {
290
        Some(schema) => value_to_string(&example_for_schema(doc, deref(doc, schema))),
291
        None => String::new(),
292
    }
293
}
294
295
/// Explicitly authored example/default on the parameter or its schema.
296
fn explicit_param_value(doc: &Value, p: &Value) -> Option<String> {
297
    if let Some(ex) = p.get("example") {
298
        return Some(value_to_string(ex));
299
    }
300
    if let Some(exs) = p.get("examples").and_then(Value::as_object)
301
        && let Some((_, first)) = exs.iter().next()
302
    {
303
        let first = deref(doc, first);
304
        if let Some(v) = first.get("value") {
305
            return Some(value_to_string(v));
306
        }
307
    }
308
    let schema = deref(doc, p.get("schema")?);
309
    if let Some(ex) = schema.get("example") {
310
        return Some(value_to_string(ex));
311
    }
312
    if let Some(def) = schema.get("default") {
313
        return Some(value_to_string(def));
314
    }
315
    None
316
}
317
318
/// Request body from `requestBody`, preferring JSON media types; falls back to
319
/// a schema-generated example so payloads are always populated and editable.
320
fn extract_body(doc: &Value, op: &Value) -> Option<String> {
321
    let media = body_media(doc, op)?;
322
323
    if let Some(ex) = media.get("example") {
324
        return Some(body_to_string(ex));
325
    }
326
    if let Some(exs) = media.get("examples").and_then(Value::as_object)
327
        && let Some((_, first)) = exs.iter().next()
328
    {
329
        let first = deref(doc, first);
330
        if let Some(v) = first.get("value") {
331
            return Some(body_to_string(v));
332
        }
333
    }
334
    let schema = media.get("schema")?;
335
    Some(body_to_string(&example_for_schema(doc, schema)))
336
}
337
338
/// The media-type entry of `requestBody` that cielago sends — and therefore
339
/// the one both the generated body and the Docs tab describe.
340
fn body_media<'a>(doc: &'a Value, op: &'a Value) -> Option<&'a Value> {
341
    let rb = deref(doc, op.get("requestBody")?);
342
    let content = rb.get("content").and_then(Value::as_object)?;
343
    pick_media(content).map(|(_, media)| media)
344
}
345
346
/// Preferred entry from a `content` map: JSON first, then anything JSON-ish,
347
/// then whatever the spec listed first.
348
fn pick_media(content: &serde_json::Map<String, Value>) -> Option<(&String, &Value)> {
349
    content
350
        .iter()
351
        .find(|(k, _)| k.as_str() == "application/json")
352
        .or_else(|| content.iter().find(|(k, _)| k.contains("json")))
353
        .or_else(|| content.iter().next())
354
}
355
356
fn value_to_string(v: &Value) -> String {
357
    match v {
358
        Value::String(s) => s.clone(),
359
        other => serde_json::to_string(other).unwrap_or_default(),
360
    }
361
}
362
363
fn body_to_string(v: &Value) -> String {
364
    match v {
365
        Value::String(s) => s.clone(),
366
        other => serde_json::to_string_pretty(other).unwrap_or_default(),
367
    }
368
}