src/openapi/docs.rs 14.8 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
    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> {
66
    let mut out = Vec::new();
67
    flatten(doc, schema, "", 0, location, &mut out);
68
    if out.is_empty() {
69
        let mut field = field_doc(doc, schema);
70
        if !field.ty.is_empty() && field.ty != "object" {
71
            field.name = format!("({location})");
72
            field.location = location.into();
73
            out.push(field);
74
        }
75
    }
76
    out
77
}
78
79
fn flatten(
80
    doc: &Value,
81
    schema: &Value,
82
    prefix: &str,
83
    depth: usize,
84
    location: &str,
85
    out: &mut Vec<FieldDoc>,
86
) {
87
    if depth > MAX_BODY_DEPTH || out.len() >= MAX_BODY_FIELDS {
88
        return;
89
    }
90
    let schema = deref(doc, schema);
91
92
    // An array contributes no fields of its own; describe its items under
93
    // `name[]` so the path reads like the JSON it documents.
94
    if let Some(items) = schema.get("items") {
95
        flatten(doc, items, &format!("{prefix}[]"), depth + 1, location, out);
96
        return;
97
    }
98
99
    for part in object_parts(doc, schema) {
100
        let required: HashSet<&str> = part
101
            .get("required")
102
            .and_then(Value::as_array)
103
            .map(|a| a.iter().filter_map(Value::as_str).collect())
104
            .unwrap_or_default();
105
        let Some(props) = part.get("properties").and_then(Value::as_object) else {
106
            continue;
107
        };
108
        for (name, sub) in props {
109
            if out.len() >= MAX_BODY_FIELDS {
110
                return;
111
            }
112
            let sub = deref(doc, sub);
113
            let path = if prefix.is_empty() {
114
                name.clone()
115
            } else {
116
                format!("{prefix}.{name}")
117
            };
118
            let mut field = field_doc(doc, sub);
119
            field.name = path.clone();
120
            field.location = location.into();
121
            field.required = required.contains(name.as_str());
122
            out.push(field);
123
            // Scalars fall straight back out of this call.
124
            flatten(doc, sub, &path, depth + 1, location, out);
125
        }
126
    }
127
}
128
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.
133
fn object_parts<'a>(doc: &'a Value, schema: &'a Value) -> Vec<&'a Value> {
134
    let mut parts = vec![schema];
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
        }
139
    }
140
    parts
141
}
142
143
/// Everything about a schema except the name and location, which only the
144
/// caller knows.
145
fn field_doc(doc: &Value, schema: &Value) -> FieldDoc {
146
    let schema = deref(doc, schema);
147
    FieldDoc {
148
        name: String::new(),
149
        location: String::new(),
150
        ty: type_label(doc, schema, 0),
151
        required: false,
152
        options: enum_options(doc, schema),
153
        description: description(schema),
154
        default: schema.get("default").map(scalar),
155
    }
156
}
157
158
/// A short, readable type: `string(uuid)`, `array<integer>`, `object`.
159
fn type_label(doc: &Value, schema: &Value, depth: usize) -> String {
160
    if depth > MAX_BODY_DEPTH {
161
        return "…".into();
162
    }
163
    let schema = deref(doc, schema);
164
165
    for key in ["oneOf", "anyOf"] {
166
        if let Some(alts) = schema.get(key).and_then(Value::as_array) {
167
            let labels: Vec<String> = alts
168
                .iter()
169
                .take(3)
170
                .map(|s| type_label(doc, s, depth + 1))
171
                .collect();
172
            let more = if alts.len() > 3 { " | …" } else { "" };
173
            return format!("{}{more}", labels.join(" | "));
174
        }
175
    }
176
    if schema.get("allOf").is_some() {
177
        return "object".into();
178
    }
179
180
    let types = type_names(schema);
181
    let Some(primary) = types.first() else {
182
        return if schema.get("properties").is_some() {
183
            "object".into()
184
        } else {
185
            "any".into()
186
        };
187
    };
188
189
    let mut label = match primary.as_str() {
190
        "array" => {
191
            let inner = schema
192
                .get("items")
193
                .map(|i| type_label(doc, i, depth + 1))
194
                .unwrap_or_else(|| "any".into());
195
            format!("array<{inner}>")
196
        }
197
        other => match schema.get("format").and_then(Value::as_str) {
198
            Some(f) => format!("{other}({f})"),
199
            None => other.to_string(),
200
        },
201
    };
202
    // OpenAPI 3.1 `type: [string, "null"]`.
203
    for extra in types.iter().skip(1) {
204
        label.push_str(" | ");
205
        label.push_str(extra);
206
    }
207
    label
208
}
209
210
/// `type` as a list — a plain string in 3.0, possibly an array in 3.1.
211
fn type_names(schema: &Value) -> Vec<String> {
212
    match schema.get("type") {
213
        Some(Value::String(s)) => vec![s.clone()],
214
        Some(Value::Array(a)) => a
215
            .iter()
216
            .filter_map(Value::as_str)
217
            .map(String::from)
218
            .collect(),
219
        _ => Vec::new(),
220
    }
221
}
222
223
/// Accepted values: the schema's own `enum`, or an array's item `enum` (the
224
/// options are what goes *in* the array either way).
225
fn enum_options(doc: &Value, schema: &Value) -> Vec<String> {
226
    let direct = schema.get("enum").and_then(Value::as_array);
227
    let from_items = || {
228
        deref(doc, schema.get("items")?)
229
            .get("enum")
230
            .and_then(Value::as_array)
231
    };
232
    direct
233
        .or_else(from_items)
234
        .map(|a| a.iter().map(scalar).collect())
235
        .unwrap_or_default()
236
}
237
238
fn description(v: &Value) -> Option<String> {
239
    let d = v.get("description").and_then(Value::as_str)?.trim();
240
    (!d.is_empty()).then(|| d.to_string())
241
}
242
243
/// Enum entries and defaults are shown as they'd be typed into a field, so
244
/// strings lose their quotes.
245
fn scalar(v: &Value) -> String {
246
    match v {
247
        Value::String(s) => s.clone(),
248
        other => other.to_string(),
249
    }
250
}
251
252
#[cfg(test)]
253
mod tests {
254
    use super::*;
255
    use serde_json::json;
256
257
    #[test]
258
    fn parameter_types_enums_and_requiredness() {
259
        let doc = json!({});
260
        let p = json!({
261
            "name": "status",
262
            "in": "query",
263
            "required": true,
264
            "description": "Status values to filter by",
265
            "schema": {"type": "string", "enum": ["available", "pending", "sold"], "default": "available"}
266
        });
267
        let d = param_doc(&doc, &p);
268
        assert_eq!(d.name, "status");
269
        assert_eq!(d.location, "query");
270
        assert_eq!(d.ty, "string");
271
        assert!(d.required);
272
        assert_eq!(d.options, ["available", "pending", "sold"]);
273
        assert_eq!(d.default.as_deref(), Some("available"));
274
        assert_eq!(d.description.as_deref(), Some("Status values to filter by"));
275
    }
276
277
    #[test]
278
    fn path_params_are_required_even_when_unflagged() {
279
        let doc = json!({});
280
        let p = json!({"name": "petId", "in": "path", "schema": {"type": "integer", "format": "int64"}});
281
        let d = param_doc(&doc, &p);
282
        assert!(d.required);
283
        assert_eq!(d.ty, "integer(int64)");
284
    }
285
286
    #[test]
287
    fn array_params_expose_item_options() {
288
        let doc = json!({});
289
        let p = json!({
290
            "name": "tags",
291
            "in": "query",
292
            "schema": {"type": "array", "items": {"type": "string", "enum": ["a", "b"]}}
293
        });
294
        let d = param_doc(&doc, &p);
295
        assert_eq!(d.ty, "array<string>");
296
        assert_eq!(d.options, ["a", "b"]);
297
    }
298
299
    #[test]
300
    fn body_is_flattened_to_dotted_paths() {
301
        let doc = json!({
302
            "components": {"schemas": {
303
                "Address": {"type": "object", "required": ["zip"], "properties": {
304
                    "zip": {"type": "string"}
305
                }}
306
            }}
307
        });
308
        let schema = json!({
309
            "type": "object",
310
            "required": ["name"],
311
            "properties": {
312
                "name": {"type": "string"},
313
                "owner": {"type": "object", "properties": {
314
                    "address": {"$ref": "#/components/schemas/Address"}
315
                }},
316
                "pets": {"type": "array", "items": {"type": "object", "properties": {
317
                    "tag": {"type": "string", "enum": ["cat", "dog"]}
318
                }}}
319
            }
320
        });
321
        let docs = body_docs(&doc, &schema);
322
        let names: Vec<&str> = docs.iter().map(|d| d.name.as_str()).collect();
323
        assert_eq!(
324
            names,
325
            [
326
                "name",
327
                "owner",
328
                "owner.address",
329
                "owner.address.zip",
330
                "pets",
331
                "pets[].tag"
332
            ]
333
        );
334
        assert!(docs[0].required);
335
        assert!(!docs[1].required);
336
        let zip = docs.iter().find(|d| d.name == "owner.address.zip").unwrap();
337
        assert!(zip.required, "requiredness comes from the owning object");
338
        let tag = docs.iter().find(|d| d.name == "pets[].tag").unwrap();
339
        assert_eq!(tag.options, ["cat", "dog"]);
340
        assert_eq!(
341
            docs.iter().find(|d| d.name == "pets").unwrap().ty,
342
            "array<object>"
343
        );
344
        assert!(docs.iter().all(|d| d.location == "body"));
345
    }
346
347
    #[test]
348
    fn all_of_members_contribute_fields() {
349
        let doc = json!({});
350
        let schema = json!({"allOf": [
351
            {"type": "object", "required": ["id"], "properties": {"id": {"type": "integer"}}},
352
            {"type": "object", "properties": {"note": {"type": "string"}}}
353
        ]});
354
        let docs = body_docs(&doc, &schema);
355
        let names: Vec<&str> = docs.iter().map(|d| d.name.as_str()).collect();
356
        assert_eq!(names, ["id", "note"]);
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");
400
    }
401
402
    #[test]
403
    fn non_object_body_gets_one_row() {
404
        let doc = json!({});
405
        let docs = body_docs(&doc, &json!({"type": "string", "format": "binary"}));
406
        assert_eq!(docs.len(), 1);
407
        assert_eq!(docs[0].name, "(body)");
408
        assert_eq!(docs[0].ty, "string(binary)");
409
    }
410
411
    #[test]
412
    fn recursive_schemas_terminate() {
413
        let doc = json!({
414
            "components": {"schemas": {
415
                "Node": {"type": "object", "properties": {
416
                    "child": {"$ref": "#/components/schemas/Node"}
417
                }}
418
            }}
419
        });
420
        let docs = body_docs(&doc, &json!({"$ref": "#/components/schemas/Node"}));
421
        assert!(!docs.is_empty());
422
        assert!(docs.len() <= MAX_BODY_DEPTH + 1, "{}", docs.len());
423
    }
424
425
    #[test]
426
    fn union_and_nullable_types_read_as_written() {
427
        let doc = json!({});
428
        assert_eq!(
429
            type_label(&doc, &json!({"type": ["string", "null"]}), 0),
430
            "string | null"
431
        );
432
        assert_eq!(
433
            type_label(
434
                &doc,
435
                &json!({"oneOf": [{"type": "string"}, {"type": "integer"}]}),
436
                0
437
            ),
438
            "string | integer"
439
        );
440
        assert_eq!(type_label(&doc, &json!({}), 0), "any");
441
    }
442
}