src/openapi/import.rs 12.7 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, response_docs};
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
    if let Some(content) = success_response_content(doc, op)
179
        && let Some((_, media)) = pick_media(content)
180
        && let Some(schema) = media.get("schema")
181
    {
182
        req.docs.extend(response_docs(doc, schema));
183
    }
184
    req
185
}
186
187
/// Headers an operation carries by definition rather than by parameter: the
188
/// media type it consumes, the one it produces, and any apiKey-in-header
189
/// security scheme it requires. API-key rows arrive disabled — the value is
190
/// the user's to supply.
191
fn implied_headers(doc: &Value, op: &Value) -> Vec<KeyValueRow> {
192
    let mut out = Vec::new();
193
    if let Some(ct) = request_media_type(doc, op) {
194
        out.push(KeyValueRow::new("Content-Type", ct, true));
195
    }
196
    if let Some(accept) = response_media_type(doc, op) {
197
        out.push(KeyValueRow::new("Accept", accept, true));
198
    }
199
    for name in api_key_headers(doc, op) {
200
        out.push(KeyValueRow::new(name, "", false));
201
    }
202
    out
203
}
204
205
fn request_media_type(doc: &Value, op: &Value) -> Option<String> {
206
    let rb = deref(doc, op.get("requestBody")?);
207
    let content = rb.get("content").and_then(Value::as_object)?;
208
    pick_media(content).map(|(k, _)| k.clone())
209
}
210
211
/// Media type from the first success response (or `default`), so `Accept`
212
/// matches what the endpoint actually returns.
213
fn response_media_type(doc: &Value, op: &Value) -> Option<String> {
214
    let content = success_response_content(doc, op)?;
215
    pick_media(content).map(|(k, _)| k.clone())
216
}
217
218
/// The `content` map of the first success response (or `default`) — the shape
219
/// the endpoint returns, used for both the `Accept` header and response docs.
220
fn success_response_content<'a>(
221
    doc: &'a Value,
222
    op: &'a Value,
223
) -> Option<&'a serde_json::Map<String, Value>> {
224
    let responses = op.get("responses").and_then(Value::as_object)?;
225
    let resp = responses
226
        .iter()
227
        .find(|(code, _)| code.starts_with('2'))
228
        .or_else(|| {
229
            responses
230
                .iter()
231
                .find(|(code, _)| code.as_str() == "default")
232
        })
233
        .map(|(_, v)| v)?;
234
    deref(doc, resp).get("content").and_then(Value::as_object)
235
}
236
237
/// Header names from apiKey security schemes this operation requires,
238
/// preferring operation-level `security` over the document default.
239
fn api_key_headers(doc: &Value, op: &Value) -> Vec<String> {
240
    let Some(requirements) = op
241
        .get("security")
242
        .or_else(|| doc.get("security"))
243
        .and_then(Value::as_array)
244
    else {
245
        return Vec::new();
246
    };
247
    let Some(schemes) = doc
248
        .get("components")
249
        .and_then(|c| c.get("securitySchemes"))
250
        .and_then(Value::as_object)
251
    else {
252
        return Vec::new();
253
    };
254
255
    let mut out: Vec<String> = Vec::new();
256
    for requirement in requirements {
257
        let Some(obj) = requirement.as_object() else {
258
            continue;
259
        };
260
        for scheme_name in obj.keys() {
261
            let Some(scheme) = schemes.get(scheme_name) else {
262
                continue;
263
            };
264
            let scheme = deref(doc, scheme);
265
            if scheme.get("type").and_then(Value::as_str) != Some("apiKey")
266
                || scheme.get("in").and_then(Value::as_str) != Some("header")
267
            {
268
                continue;
269
            }
270
            if let Some(name) = scheme.get("name").and_then(Value::as_str)
271
                && !name.is_empty()
272
                && !out.iter().any(|e| e.eq_ignore_ascii_case(name))
273
            {
274
                out.push(name.to_string());
275
            }
276
        }
277
    }
278
    out
279
}
280
281
fn param_key(p: &Value) -> (String, String) {
282
    (
283
        p.get("name")
284
            .and_then(Value::as_str)
285
            .unwrap_or_default()
286
            .into(),
287
        p.get("in")
288
            .and_then(Value::as_str)
289
            .unwrap_or_default()
290
            .into(),
291
    )
292
}
293
294
/// Value for a parameter. Required params fall back to type-based stubs so the
295
/// request is sendable out of the box; optional params only get explicitly
296
/// authored examples/defaults (otherwise empty).
297
fn param_value(doc: &Value, p: &Value, required: bool) -> String {
298
    if let Some(v) = explicit_param_value(doc, p) {
299
        return v;
300
    }
301
    if !required {
302
        return String::new();
303
    }
304
    match p.get("schema") {
305
        Some(schema) => value_to_string(&example_for_schema(doc, deref(doc, schema))),
306
        None => String::new(),
307
    }
308
}
309
310
/// Explicitly authored example/default on the parameter or its schema.
311
fn explicit_param_value(doc: &Value, p: &Value) -> Option<String> {
312
    if let Some(ex) = p.get("example") {
313
        return Some(value_to_string(ex));
314
    }
315
    if let Some(exs) = p.get("examples").and_then(Value::as_object)
316
        && let Some((_, first)) = exs.iter().next()
317
    {
318
        let first = deref(doc, first);
319
        if let Some(v) = first.get("value") {
320
            return Some(value_to_string(v));
321
        }
322
    }
323
    let schema = deref(doc, p.get("schema")?);
324
    if let Some(ex) = schema.get("example") {
325
        return Some(value_to_string(ex));
326
    }
327
    if let Some(def) = schema.get("default") {
328
        return Some(value_to_string(def));
329
    }
330
    None
331
}
332
333
/// Request body from `requestBody`, preferring JSON media types; falls back to
334
/// a schema-generated example so payloads are always populated and editable.
335
fn extract_body(doc: &Value, op: &Value) -> Option<String> {
336
    let media = body_media(doc, op)?;
337
338
    if let Some(ex) = media.get("example") {
339
        return Some(body_to_string(ex));
340
    }
341
    if let Some(exs) = media.get("examples").and_then(Value::as_object)
342
        && let Some((_, first)) = exs.iter().next()
343
    {
344
        let first = deref(doc, first);
345
        if let Some(v) = first.get("value") {
346
            return Some(body_to_string(v));
347
        }
348
    }
349
    let schema = media.get("schema")?;
350
    Some(body_to_string(&example_for_schema(doc, schema)))
351
}
352
353
/// The media-type entry of `requestBody` that cielago sends — and therefore
354
/// the one both the generated body and the Docs tab describe.
355
fn body_media<'a>(doc: &'a Value, op: &'a Value) -> Option<&'a Value> {
356
    let rb = deref(doc, op.get("requestBody")?);
357
    let content = rb.get("content").and_then(Value::as_object)?;
358
    pick_media(content).map(|(_, media)| media)
359
}
360
361
/// Preferred entry from a `content` map: JSON first, then anything JSON-ish,
362
/// then whatever the spec listed first.
363
fn pick_media(content: &serde_json::Map<String, Value>) -> Option<(&String, &Value)> {
364
    content
365
        .iter()
366
        .find(|(k, _)| k.as_str() == "application/json")
367
        .or_else(|| content.iter().find(|(k, _)| k.contains("json")))
368
        .or_else(|| content.iter().next())
369
}
370
371
fn value_to_string(v: &Value) -> String {
372
    match v {
373
        Value::String(s) => s.clone(),
374
        other => serde_json::to_string(other).unwrap_or_default(),
375
    }
376
}
377
378
fn body_to_string(v: &Value) -> String {
379
    match v {
380
        Value::String(s) => s.clone(),
381
        other => serde_json::to_string_pretty(other).unwrap_or_default(),
382
    }
383
}