chore: improved spec docs import to include schemas ad94cedb
Steve Simkins · 2026-08-10 22:40 3 file(s) · +95 −14
src/openapi/docs.rs +76 −11
51 51
/// Documentation for a request body schema, flattened to dotted paths:
52 52
/// `owner.name`, `pets[].tag`. A body that isn't an object gets a single row.
53 53
pub fn body_docs(doc: &Value, schema: &Value) -> Vec<FieldDoc> {
54 +
    schema_docs(doc, schema, "body")
55 +
}
56 +
57 +
/// Same flattening as [`body_docs`], for a success response schema. Rows land
58 +
/// under `location: "response"` so the Docs tab lists them separately.
59 +
pub fn response_docs(doc: &Value, schema: &Value) -> Vec<FieldDoc> {
60 +
    schema_docs(doc, schema, "response")
61 +
}
62 +
63 +
/// Flatten `schema` into dotted-path rows tagged with `location`. A schema that
64 +
/// isn't an object gets a single `(location)` row.
65 +
fn schema_docs(doc: &Value, schema: &Value, location: &str) -> Vec<FieldDoc> {
54 66
    let mut out = Vec::new();
55 -
    flatten(doc, schema, "", 0, &mut out);
67 +
    flatten(doc, schema, "", 0, location, &mut out);
56 68
    if out.is_empty() {
57 69
        let mut field = field_doc(doc, schema);
58 70
        if !field.ty.is_empty() && field.ty != "object" {
59 -
            field.name = "(body)".into();
60 -
            field.location = "body".into();
71 +
            field.name = format!("({location})");
72 +
            field.location = location.into();
61 73
            out.push(field);
62 74
        }
63 75
    }
64 76
    out
65 77
}
66 78
67 -
fn flatten(doc: &Value, schema: &Value, prefix: &str, depth: usize, out: &mut Vec<FieldDoc>) {
79 +
fn flatten(
80 +
    doc: &Value,
81 +
    schema: &Value,
82 +
    prefix: &str,
83 +
    depth: usize,
84 +
    location: &str,
85 +
    out: &mut Vec<FieldDoc>,
86 +
) {
68 87
    if depth > MAX_BODY_DEPTH || out.len() >= MAX_BODY_FIELDS {
69 88
        return;
70 89
    }
73 92
    // An array contributes no fields of its own; describe its items under
74 93
    // `name[]` so the path reads like the JSON it documents.
75 94
    if let Some(items) = schema.get("items") {
76 -
        flatten(doc, items, &format!("{prefix}[]"), depth + 1, out);
95 +
        flatten(doc, items, &format!("{prefix}[]"), depth + 1, location, out);
77 96
        return;
78 97
    }
79 98
98 117
            };
99 118
            let mut field = field_doc(doc, sub);
100 119
            field.name = path.clone();
101 -
            field.location = "body".into();
120 +
            field.location = location.into();
102 121
            field.required = required.contains(name.as_str());
103 122
            out.push(field);
104 123
            // Scalars fall straight back out of this call.
105 -
            flatten(doc, sub, &path, depth + 1, out);
124 +
            flatten(doc, sub, &path, depth + 1, location, out);
106 125
        }
107 126
    }
108 127
}
109 128
110 -
/// Schemas contributing properties to `schema`: itself, plus `allOf` members,
111 -
/// which OpenAPI uses for composition/inheritance.
129 +
/// Schemas contributing properties to `schema`: itself, plus the members of any
130 +
/// `allOf`/`oneOf`/`anyOf`. `allOf` is composition; `oneOf`/`anyOf` are
131 +
/// alternatives, but the Docs tab merges every branch's fields flat so nested
132 +
/// objects behind a union still show up.
112 133
fn object_parts<'a>(doc: &'a Value, schema: &'a Value) -> Vec<&'a Value> {
113 134
    let mut parts = vec![schema];
114 -
    if let Some(all) = schema.get("allOf").and_then(Value::as_array) {
115 -
        parts.extend(all.iter().map(|s| deref(doc, s)));
135 +
    for key in ["allOf", "oneOf", "anyOf"] {
136 +
        if let Some(members) = schema.get(key).and_then(Value::as_array) {
137 +
            parts.extend(members.iter().map(|s| deref(doc, s)));
138 +
        }
116 139
    }
117 140
    parts
118 141
}
332 355
        let names: Vec<&str> = docs.iter().map(|d| d.name.as_str()).collect();
333 356
        assert_eq!(names, ["id", "note"]);
334 357
        assert!(docs[0].required);
358 +
    }
359 +
360 +
    #[test]
361 +
    fn one_of_object_variants_contribute_nested_fields() {
362 +
        let doc = json!({});
363 +
        // A property whose value is a union of two object shapes: both branches'
364 +
        // fields flatten under the property path.
365 +
        let schema = json!({"type": "object", "properties": {
366 +
            "payment": {"oneOf": [
367 +
                {"type": "object", "properties": {"card": {"type": "string"}}},
368 +
                {"type": "object", "properties": {"iban": {"type": "string"}}}
369 +
            ]}
370 +
        }});
371 +
        let names: Vec<String> = body_docs(&doc, &schema)
372 +
            .into_iter()
373 +
            .map(|d| d.name)
374 +
            .collect();
375 +
        assert!(names.contains(&"payment".to_string()));
376 +
        assert!(names.contains(&"payment.card".to_string()));
377 +
        assert!(names.contains(&"payment.iban".to_string()));
378 +
    }
379 +
380 +
    #[test]
381 +
    fn response_docs_are_flattened_and_located() {
382 +
        let doc = json!({});
383 +
        let schema = json!({"type": "object", "properties": {
384 +
            "id": {"type": "integer"},
385 +
            "owner": {"type": "object", "properties": {"name": {"type": "string"}}}
386 +
        }});
387 +
        let docs = response_docs(&doc, &schema);
388 +
        let names: Vec<&str> = docs.iter().map(|d| d.name.as_str()).collect();
389 +
        assert_eq!(names, ["id", "owner", "owner.name"]);
390 +
        assert!(docs.iter().all(|d| d.location == "response"));
391 +
    }
392 +
393 +
    #[test]
394 +
    fn non_object_response_gets_one_row() {
395 +
        let doc = json!({});
396 +
        let docs = response_docs(&doc, &json!({"type": "string"}));
397 +
        assert_eq!(docs.len(), 1);
398 +
        assert_eq!(docs[0].name, "(response)");
399 +
        assert_eq!(docs[0].location, "response");
335 400
    }
336 401
337 402
    #[test]
src/openapi/import.rs +18 −3
4 4
5 5
use serde_json::Value;
6 6
7 -
use super::docs::{body_docs, param_doc};
7 +
use super::docs::{body_docs, param_doc, response_docs};
8 8
use super::examples::example_for_schema;
9 9
use super::resolve::deref;
10 10
use crate::model::{AuthStyle, Collection, KeyValueRow, Method, OAuthConfig, SavedRequest};
175 175
    if let Some(schema) = body_media(doc, op).and_then(|m| m.get("schema")) {
176 176
        req.docs.extend(body_docs(doc, schema));
177 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 +
    }
178 184
    req
179 185
}
180 186
205 211
/// Media type from the first success response (or `default`), so `Accept`
206 212
/// matches what the endpoint actually returns.
207 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>> {
208 224
    let responses = op.get("responses").and_then(Value::as_object)?;
209 225
    let resp = responses
210 226
        .iter()
215 231
                .find(|(code, _)| code.as_str() == "default")
216 232
        })
217 233
        .map(|(_, v)| v)?;
218 -
    let content = deref(doc, resp).get("content").and_then(Value::as_object)?;
219 -
    pick_media(content).map(|(k, _)| k.clone())
234 +
    deref(doc, resp).get("content").and_then(Value::as_object)
220 235
}
221 236
222 237
/// Header names from apiKey security schemes this operation requires,
src/ui.rs +1 −0
290 290
                ("query", "Query params"),
291 291
                ("header", "Headers"),
292 292
                ("body", "Body"),
293 +
                ("response", "Response"),
293 294
            ] {
294 295
                let fields = req.docs.iter().filter(|d| d.location == location);
295 296
                let mut first = true;