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