src/openapi/docs.rs 12.4 K raw
1
//! Turning schemas into the [`FieldDoc`]s the Docs tab renders: what type a
2
//! parameter or body field is, whether it's required, and which values it
3
//! accepts.
4
//!
5
//! This is a summary, not a spec viewer — the aim is answering "what can I put
6
//! here?" without leaving the terminal.
7
8
use std::collections::HashSet;
9
10
use serde_json::Value;
11
12
use super::resolve::deref;
13
use crate::model::FieldDoc;
14
15
/// Nesting cap when flattening a body schema. Also what terminates recursive
16
/// schemas, the same way [`super::examples`] caps generation depth.
17
const MAX_BODY_DEPTH: usize = 4;
18
19
/// Upper bound on body fields per request, so a sprawling schema can't turn
20
/// the Docs tab into thousands of lines.
21
const MAX_BODY_FIELDS: usize = 200;
22
23
/// Documentation for one OpenAPI parameter object.
24
pub fn param_doc(doc: &Value, p: &Value) -> FieldDoc {
25
    let location = p.get("in").and_then(Value::as_str).unwrap_or("query");
26
    let schema = p.get("schema").map(|s| deref(doc, s));
27
    let mut field = match schema {
28
        Some(schema) => field_doc(doc, schema),
29
        None => FieldDoc {
30
            ty: "string".into(),
31
            ..FieldDoc::default()
32
        },
33
    };
34
    field.name = p
35
        .get("name")
36
        .and_then(Value::as_str)
37
        .unwrap_or_default()
38
        .to_string();
39
    field.location = location.to_string();
40
    // Path parameters are required by definition (OpenAPI says so even when
41
    // the spec omits the flag).
42
    field.required =
43
        location == "path" || p.get("required").and_then(Value::as_bool).unwrap_or(false);
44
    // A description on the parameter beats one inherited from its schema.
45
    if let Some(d) = description(p) {
46
        field.description = Some(d);
47
    }
48
    field
49
}
50
51
/// Documentation for a request body schema, flattened to dotted paths:
52
/// `owner.name`, `pets[].tag`. A body that isn't an object gets a single row.
53
pub fn body_docs(doc: &Value, schema: &Value) -> Vec<FieldDoc> {
54
    let mut out = Vec::new();
55
    flatten(doc, schema, "", 0, &mut out);
56
    if out.is_empty() {
57
        let mut field = field_doc(doc, schema);
58
        if !field.ty.is_empty() && field.ty != "object" {
59
            field.name = "(body)".into();
60
            field.location = "body".into();
61
            out.push(field);
62
        }
63
    }
64
    out
65
}
66
67
fn flatten(doc: &Value, schema: &Value, prefix: &str, depth: usize, out: &mut Vec<FieldDoc>) {
68
    if depth > MAX_BODY_DEPTH || out.len() >= MAX_BODY_FIELDS {
69
        return;
70
    }
71
    let schema = deref(doc, schema);
72
73
    // An array contributes no fields of its own; describe its items under
74
    // `name[]` so the path reads like the JSON it documents.
75
    if let Some(items) = schema.get("items") {
76
        flatten(doc, items, &format!("{prefix}[]"), depth + 1, out);
77
        return;
78
    }
79
80
    for part in object_parts(doc, schema) {
81
        let required: HashSet<&str> = part
82
            .get("required")
83
            .and_then(Value::as_array)
84
            .map(|a| a.iter().filter_map(Value::as_str).collect())
85
            .unwrap_or_default();
86
        let Some(props) = part.get("properties").and_then(Value::as_object) else {
87
            continue;
88
        };
89
        for (name, sub) in props {
90
            if out.len() >= MAX_BODY_FIELDS {
91
                return;
92
            }
93
            let sub = deref(doc, sub);
94
            let path = if prefix.is_empty() {
95
                name.clone()
96
            } else {
97
                format!("{prefix}.{name}")
98
            };
99
            let mut field = field_doc(doc, sub);
100
            field.name = path.clone();
101
            field.location = "body".into();
102
            field.required = required.contains(name.as_str());
103
            out.push(field);
104
            // Scalars fall straight back out of this call.
105
            flatten(doc, sub, &path, depth + 1, out);
106
        }
107
    }
108
}
109
110
/// Schemas contributing properties to `schema`: itself, plus `allOf` members,
111
/// which OpenAPI uses for composition/inheritance.
112
fn object_parts<'a>(doc: &'a Value, schema: &'a Value) -> Vec<&'a Value> {
113
    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)));
116
    }
117
    parts
118
}
119
120
/// Everything about a schema except the name and location, which only the
121
/// caller knows.
122
fn field_doc(doc: &Value, schema: &Value) -> FieldDoc {
123
    let schema = deref(doc, schema);
124
    FieldDoc {
125
        name: String::new(),
126
        location: String::new(),
127
        ty: type_label(doc, schema, 0),
128
        required: false,
129
        options: enum_options(doc, schema),
130
        description: description(schema),
131
        default: schema.get("default").map(scalar),
132
    }
133
}
134
135
/// A short, readable type: `string(uuid)`, `array<integer>`, `object`.
136
fn type_label(doc: &Value, schema: &Value, depth: usize) -> String {
137
    if depth > MAX_BODY_DEPTH {
138
        return "…".into();
139
    }
140
    let schema = deref(doc, schema);
141
142
    for key in ["oneOf", "anyOf"] {
143
        if let Some(alts) = schema.get(key).and_then(Value::as_array) {
144
            let labels: Vec<String> = alts
145
                .iter()
146
                .take(3)
147
                .map(|s| type_label(doc, s, depth + 1))
148
                .collect();
149
            let more = if alts.len() > 3 { " | …" } else { "" };
150
            return format!("{}{more}", labels.join(" | "));
151
        }
152
    }
153
    if schema.get("allOf").is_some() {
154
        return "object".into();
155
    }
156
157
    let types = type_names(schema);
158
    let Some(primary) = types.first() else {
159
        return if schema.get("properties").is_some() {
160
            "object".into()
161
        } else {
162
            "any".into()
163
        };
164
    };
165
166
    let mut label = match primary.as_str() {
167
        "array" => {
168
            let inner = schema
169
                .get("items")
170
                .map(|i| type_label(doc, i, depth + 1))
171
                .unwrap_or_else(|| "any".into());
172
            format!("array<{inner}>")
173
        }
174
        other => match schema.get("format").and_then(Value::as_str) {
175
            Some(f) => format!("{other}({f})"),
176
            None => other.to_string(),
177
        },
178
    };
179
    // OpenAPI 3.1 `type: [string, "null"]`.
180
    for extra in types.iter().skip(1) {
181
        label.push_str(" | ");
182
        label.push_str(extra);
183
    }
184
    label
185
}
186
187
/// `type` as a list — a plain string in 3.0, possibly an array in 3.1.
188
fn type_names(schema: &Value) -> Vec<String> {
189
    match schema.get("type") {
190
        Some(Value::String(s)) => vec![s.clone()],
191
        Some(Value::Array(a)) => a
192
            .iter()
193
            .filter_map(Value::as_str)
194
            .map(String::from)
195
            .collect(),
196
        _ => Vec::new(),
197
    }
198
}
199
200
/// Accepted values: the schema's own `enum`, or an array's item `enum` (the
201
/// options are what goes *in* the array either way).
202
fn enum_options(doc: &Value, schema: &Value) -> Vec<String> {
203
    let direct = schema.get("enum").and_then(Value::as_array);
204
    let from_items = || {
205
        deref(doc, schema.get("items")?)
206
            .get("enum")
207
            .and_then(Value::as_array)
208
    };
209
    direct
210
        .or_else(from_items)
211
        .map(|a| a.iter().map(scalar).collect())
212
        .unwrap_or_default()
213
}
214
215
fn description(v: &Value) -> Option<String> {
216
    let d = v.get("description").and_then(Value::as_str)?.trim();
217
    (!d.is_empty()).then(|| d.to_string())
218
}
219
220
/// Enum entries and defaults are shown as they'd be typed into a field, so
221
/// strings lose their quotes.
222
fn scalar(v: &Value) -> String {
223
    match v {
224
        Value::String(s) => s.clone(),
225
        other => other.to_string(),
226
    }
227
}
228
229
#[cfg(test)]
230
mod tests {
231
    use super::*;
232
    use serde_json::json;
233
234
    #[test]
235
    fn parameter_types_enums_and_requiredness() {
236
        let doc = json!({});
237
        let p = json!({
238
            "name": "status",
239
            "in": "query",
240
            "required": true,
241
            "description": "Status values to filter by",
242
            "schema": {"type": "string", "enum": ["available", "pending", "sold"], "default": "available"}
243
        });
244
        let d = param_doc(&doc, &p);
245
        assert_eq!(d.name, "status");
246
        assert_eq!(d.location, "query");
247
        assert_eq!(d.ty, "string");
248
        assert!(d.required);
249
        assert_eq!(d.options, ["available", "pending", "sold"]);
250
        assert_eq!(d.default.as_deref(), Some("available"));
251
        assert_eq!(d.description.as_deref(), Some("Status values to filter by"));
252
    }
253
254
    #[test]
255
    fn path_params_are_required_even_when_unflagged() {
256
        let doc = json!({});
257
        let p = json!({"name": "petId", "in": "path", "schema": {"type": "integer", "format": "int64"}});
258
        let d = param_doc(&doc, &p);
259
        assert!(d.required);
260
        assert_eq!(d.ty, "integer(int64)");
261
    }
262
263
    #[test]
264
    fn array_params_expose_item_options() {
265
        let doc = json!({});
266
        let p = json!({
267
            "name": "tags",
268
            "in": "query",
269
            "schema": {"type": "array", "items": {"type": "string", "enum": ["a", "b"]}}
270
        });
271
        let d = param_doc(&doc, &p);
272
        assert_eq!(d.ty, "array<string>");
273
        assert_eq!(d.options, ["a", "b"]);
274
    }
275
276
    #[test]
277
    fn body_is_flattened_to_dotted_paths() {
278
        let doc = json!({
279
            "components": {"schemas": {
280
                "Address": {"type": "object", "required": ["zip"], "properties": {
281
                    "zip": {"type": "string"}
282
                }}
283
            }}
284
        });
285
        let schema = json!({
286
            "type": "object",
287
            "required": ["name"],
288
            "properties": {
289
                "name": {"type": "string"},
290
                "owner": {"type": "object", "properties": {
291
                    "address": {"$ref": "#/components/schemas/Address"}
292
                }},
293
                "pets": {"type": "array", "items": {"type": "object", "properties": {
294
                    "tag": {"type": "string", "enum": ["cat", "dog"]}
295
                }}}
296
            }
297
        });
298
        let docs = body_docs(&doc, &schema);
299
        let names: Vec<&str> = docs.iter().map(|d| d.name.as_str()).collect();
300
        assert_eq!(
301
            names,
302
            [
303
                "name",
304
                "owner",
305
                "owner.address",
306
                "owner.address.zip",
307
                "pets",
308
                "pets[].tag"
309
            ]
310
        );
311
        assert!(docs[0].required);
312
        assert!(!docs[1].required);
313
        let zip = docs.iter().find(|d| d.name == "owner.address.zip").unwrap();
314
        assert!(zip.required, "requiredness comes from the owning object");
315
        let tag = docs.iter().find(|d| d.name == "pets[].tag").unwrap();
316
        assert_eq!(tag.options, ["cat", "dog"]);
317
        assert_eq!(
318
            docs.iter().find(|d| d.name == "pets").unwrap().ty,
319
            "array<object>"
320
        );
321
        assert!(docs.iter().all(|d| d.location == "body"));
322
    }
323
324
    #[test]
325
    fn all_of_members_contribute_fields() {
326
        let doc = json!({});
327
        let schema = json!({"allOf": [
328
            {"type": "object", "required": ["id"], "properties": {"id": {"type": "integer"}}},
329
            {"type": "object", "properties": {"note": {"type": "string"}}}
330
        ]});
331
        let docs = body_docs(&doc, &schema);
332
        let names: Vec<&str> = docs.iter().map(|d| d.name.as_str()).collect();
333
        assert_eq!(names, ["id", "note"]);
334
        assert!(docs[0].required);
335
    }
336
337
    #[test]
338
    fn non_object_body_gets_one_row() {
339
        let doc = json!({});
340
        let docs = body_docs(&doc, &json!({"type": "string", "format": "binary"}));
341
        assert_eq!(docs.len(), 1);
342
        assert_eq!(docs[0].name, "(body)");
343
        assert_eq!(docs[0].ty, "string(binary)");
344
    }
345
346
    #[test]
347
    fn recursive_schemas_terminate() {
348
        let doc = json!({
349
            "components": {"schemas": {
350
                "Node": {"type": "object", "properties": {
351
                    "child": {"$ref": "#/components/schemas/Node"}
352
                }}
353
            }}
354
        });
355
        let docs = body_docs(&doc, &json!({"$ref": "#/components/schemas/Node"}));
356
        assert!(!docs.is_empty());
357
        assert!(docs.len() <= MAX_BODY_DEPTH + 1, "{}", docs.len());
358
    }
359
360
    #[test]
361
    fn union_and_nullable_types_read_as_written() {
362
        let doc = json!({});
363
        assert_eq!(
364
            type_label(&doc, &json!({"type": ["string", "null"]}), 0),
365
            "string | null"
366
        );
367
        assert_eq!(
368
            type_label(
369
                &doc,
370
                &json!({"oneOf": [{"type": "string"}, {"type": "integer"}]}),
371
                0
372
            ),
373
            "string | integer"
374
        );
375
        assert_eq!(type_label(&doc, &json!({}), 0), "any");
376
    }
377
}