Skip to main content

krabka_docgen/
schema_md.rs

1//! Render a JSON-Schema-shaped value to a markdown field table.
2//!
3//! The input is `OpenAPI` v3 output or schemars output. The CRD generator and
4//! the broker-config generator share this module.
5
6use {serde_json::Value, std::fmt::Write};
7
8/// Render the `properties` of an object schema as a markdown table.
9///
10/// The table has one row per field, and a field can be nested. The columns are
11/// Field, Type, Required, Default, and Description. Nested object properties
12/// recurse with a dotted path.
13///
14/// The argument is also the root schema that resolves `$ref` pointers. schemars
15/// emits `$ref` into a top-level `$defs` or `definitions` table for nested
16/// structs. Kube-generated CRD schemas are fully inlined and carry no refs, so
17/// they render identically.
18#[must_use]
19pub fn render_field_table(schema: &Value) -> String {
20    let mut rows = String::new();
21    collect_rows(schema, schema, "", 0, &mut rows);
22    let mut out = field_table_header();
23    out.push_str(&rows);
24    out
25}
26
27/// Render the root schema's top-level properties as separate, captioned
28/// subsections instead of one flat table.
29///
30/// These subsections are easier to scan than the large dense table that
31/// `render_field_table` produces.
32///
33/// Each top-level property that resolves to an object, that is to a schema with
34/// `properties`, gets its own `## <key>` heading, its description as a blurb,
35/// and a focused field table. The rows of that table are relative to the
36/// subtree, so the function removes the leading `<key>.` prefix. All the other
37/// top-level properties go under one leading `## General` section, as one table
38/// keyed by the bare property name. These are the scalars, the arrays, and the
39/// objects without `properties`. A `---` horizontal rule separates the
40/// sections.
41///
42/// This function shares all the type, default, description, and escaping logic
43/// with `render_field_table` through `effective_schema`, `type_label`,
44/// `render_default`, and `collect_rows`. `render_field_table` keeps its flat
45/// layout, so the CRD pages and the other pages keep that layout too.
46#[must_use]
47pub fn render_sectioned_field_table(schema: &Value) -> String {
48    let required = required_fields(schema);
49    let Some(props) = schema.get("properties").and_then(Value::as_object) else {
50        return String::new();
51    };
52
53    // Partition top-level properties: object-with-`properties` ones become
54    // their own section; everything else (scalars, arrays, refless objects)
55    // lands in the shared "General" table.
56    let mut general: Vec<TopLevelField<'_>> = Vec::new();
57    let mut sections: Vec<SectionField<'_>> = Vec::new();
58    for (name, field) in props {
59        let resolved = effective_schema(schema, field);
60        if resolved.get("properties").is_some() {
61            sections.push(SectionField {
62                name,
63                field,
64                resolved,
65            });
66        } else {
67            general.push(TopLevelField { name, field });
68        }
69    }
70
71    let mut parts: Vec<String> = Vec::new();
72
73    if !general.is_empty() {
74        let mut body = String::from("## General\n\n");
75        body.push_str(&field_table_header());
76        for TopLevelField { name, field } in general {
77            let resolved = effective_schema(schema, field);
78            let req = required.contains(&name.as_str());
79            write_field_row(schema, name, field, resolved, req, &mut body);
80        }
81        parts.push(body);
82    }
83
84    for SectionField {
85        name,
86        field,
87        resolved,
88    } in sections
89    {
90        let mut body = format!("## {name}\n\n");
91        if let Some(desc) = field
92            .get("description")
93            .or_else(|| resolved.get("description"))
94            .and_then(Value::as_str)
95        {
96            // Blurbs are prose, not table cells, so no pipe escaping needed;
97            // collapse newlines for a single tidy paragraph.
98            body.push_str(&desc.replace('\n', " "));
99            body.push_str("\n\n");
100        }
101        body.push_str(&field_table_header());
102        // Reuse the dotted-path row logic scoped to this subtree, with an empty
103        // prefix so rows read relative to the section (no leading `<key>.`).
104        collect_rows(schema, resolved, "", 0, &mut body);
105        parts.push(body);
106    }
107
108    parts.join("\n\n---\n\n")
109}
110
111/// A top-level property of the schema.
112struct TopLevelField<'a> {
113    name: &'a String,
114    field: &'a Value,
115}
116
117/// A top-level property that is an object with `properties`, and so renders as
118/// its own section. `resolved` is the schema that `field` refers to.
119struct SectionField<'a> {
120    name: &'a String,
121    field: &'a Value,
122    resolved: &'a Value,
123}
124
125/// The shared 5-column field-table header.
126///
127/// `render_field_table` holds the same header as a literal. This copy for the
128/// sectioned variant stays in sync with that literal.
129fn field_table_header() -> String {
130    String::from(
131        "| Field | Type | Required | Default | Description |\n\
132         |-------|------|----------|---------|-------------|\n",
133    )
134}
135
136/// Emit a single field row for `name`.
137///
138/// The "General" section uses this function, and it renders scalar and array
139/// top-level props as plain one-row entries. The function mirrors the per-field
140/// formatting for nested rows, so types, defaults and descriptions use the same
141/// escaping throughout the table.
142fn write_field_row(
143    root: &Value,
144    name: &str,
145    field: &Value,
146    resolved: &Value,
147    required: bool,
148    out: &mut String,
149) {
150    let ty = type_label(root, resolved);
151    let req = if required { "yes" } else { "no" };
152    let default = field
153        .get("default")
154        .or_else(|| resolved.get("default"))
155        .map_or_default(render_default);
156    let desc = field
157        .get("description")
158        .or_else(|| resolved.get("description"))
159        .and_then(Value::as_str)
160        .unwrap_or("")
161        .replace('\n', " ")
162        .replace('|', "\\|");
163    let _ = writeln!(out, "| `{name}` | {ty} | {req} | {default} | {desc} |");
164}
165
166/// Resolve a JSON-pointer `$ref` like `#/$defs/Foo` against `root`.
167///
168/// The function returns `None` for external or unresolvable refs.
169fn resolve_ref<'a>(root: &'a Value, reference: &str) -> Option<&'a Value> {
170    let pointer = reference.strip_prefix('#')?;
171    root.pointer(pointer)
172}
173
174/// Resolve a field schema to its effective underlying schema.
175///
176/// The function follows a bare `$ref` into `$defs` or `definitions`, and it
177/// unwraps schemars' `Option<T>`-style wrappers. An `anyOf`, `allOf`, or
178/// `oneOf` whose branches are `[T, null]` collapses to `T`, and the function
179/// applies this rule recursively. It returns a borrow of the effective schema
180/// `Value`. That value is the field itself when no `$ref` and no wrapper
181/// applies.
182fn effective_schema<'a>(root: &'a Value, field: &'a Value) -> &'a Value {
183    let mut current = field;
184    for _ in 0..MAX_DEPTH {
185        if let Some(reference) = current.get("$ref").and_then(Value::as_str)
186            && let Some(target) = resolve_ref(root, reference)
187        {
188            current = target;
189            continue;
190        }
191        let next = ["anyOf", "allOf", "oneOf"].into_iter().find_map(|key| {
192            let branches = current.get(key).and_then(Value::as_array)?;
193            branches
194                .iter()
195                .find(|branch| branch.get("type").and_then(Value::as_str) != Some("null"))
196        });
197        let Some(next) = next else {
198            return current;
199        };
200        current = next;
201    }
202    current
203}
204
205/// Maximum nesting depth for `$ref` and object recursion.
206///
207/// This bound limits the work on self-referential or mutually recursive
208/// schemas, so the rendering cannot overflow the stack. Deeper fields still
209/// emit their own row, but the renderer does not expand them.
210const MAX_DEPTH: usize = 12;
211
212fn collect_rows(root: &Value, schema: &Value, prefix: &str, depth: usize, out: &mut String) {
213    let required = required_fields(schema);
214    let Some(props) = schema.get("properties").and_then(Value::as_object) else {
215        return;
216    };
217    for (name, field) in props {
218        let path = if prefix.is_empty() {
219            name.clone()
220        } else {
221            format!("{prefix}.{name}")
222        };
223        let resolved = effective_schema(root, field);
224        write_field_row(
225            root,
226            &path,
227            field,
228            resolved,
229            required.contains(&name.as_str()),
230            out,
231        );
232        // Recurse into nested objects (inlined or resolved via $ref), but stop
233        // at MAX_DEPTH so cyclic/self-referential schemas can't overflow.
234        if resolved.get("properties").is_some() && depth < MAX_DEPTH {
235            collect_rows(root, resolved, &path, depth + 1, out);
236        }
237    }
238}
239
240/// First non-"null" entry of a `type` that can be a string or an array.
241///
242/// schemars emits `["string", "null"]` for `Option<T>`.
243fn primitive_type(field: &Value) -> Option<String> {
244    match field.get("type") {
245        Some(Value::String(s)) => Some(s.clone()),
246        Some(Value::Array(arr)) => arr
247            .iter()
248            .filter_map(Value::as_str)
249            .find(|t| *t != "null")
250            .map(str::to_owned),
251        _ => None,
252    }
253}
254
255fn type_label(root: &Value, field: &Value) -> String { type_label_at(root, field, 0) }
256
257fn type_label_at(root: &Value, field: &Value, depth: usize) -> String {
258    if depth >= MAX_DEPTH {
259        return "any".to_owned();
260    }
261    // Follow Option<T>/$ref wrappers before inspecting the type.
262    let field = effective_schema(root, field);
263    match primitive_type(field).as_deref() {
264        Some("array") => {
265            let item = field
266                .get("items")
267                .map_or_else(|| "any".to_owned(), |it| type_label_at(root, it, depth + 1));
268            format!("array<{item}>")
269        }
270        Some(t) => t.to_owned(),
271        None => "object".to_owned(),
272    }
273}
274
275fn render_default(v: &Value) -> String {
276    // Escape pipes so a default containing `|` can't break the table columns,
277    // even though the value is wrapped in a code span.
278    match v {
279        Value::String(s) => format!("`{}`", s.replace('|', "\\|")),
280        other => format!("`{}`", other.to_string().replace('|', "\\|")),
281    }
282}
283
284fn required_fields(schema: &Value) -> Vec<&str> {
285    schema
286        .get("required")
287        .and_then(Value::as_array)
288        .map_or_default(|a| a.iter().filter_map(Value::as_str).collect())
289}
290
291#[cfg(test)]
292mod tests {
293    use {super::*, assert2::check, serde_json::json};
294
295    #[test]
296    fn renders_nested_object_with_types_and_defaults() {
297        let schema = json!({
298            "type": "object",
299            "required": ["name"],
300            "properties": {
301                "name": { "type": "string", "description": "The thing's name." },
302                "replicas": { "type": "integer", "default": 3, "description": "How many." },
303                "nested": { "type": "object", "properties": {
304                    "enabled": { "type": "boolean", "description": "Toggle." } } }
305            }
306        });
307        let md = render_field_table(&schema);
308        for needle in [
309            "| `name` | string | yes |",
310            "The thing's name.",
311            "| `replicas` | integer | no | `3` |",
312            "`nested.enabled`",
313        ] {
314            assert2::assert!(md.contains(needle));
315        }
316    }
317
318    #[test]
319    fn renders_array_item_type() {
320        let schema = json!({ "type": "object", "properties": {
321            "ports": { "type": "array", "items": { "type": "integer" }, "description": "Listener ports." } } });
322        let md = render_field_table(&schema);
323        assert2::assert!(md.contains("`ports`"));
324        assert2::assert!(md.contains("array<integer>"));
325    }
326
327    /// Mimic schemars 1.x output. `Option<T>` types are arrays `["T","null"]`.
328    /// Nested structs are a `$ref` into `$defs`, either directly or wrapped in
329    /// `anyOf` with a `null` branch. `Vec<Struct>` arrays reference items by
330    /// ref.
331    #[test]
332    fn resolves_refs_and_nullable_type_arrays() {
333        let schema = json!({
334            "type": "object",
335            "$defs": {
336                "Tls": {
337                    "type": "object",
338                    "required": ["cert"],
339                    "properties": {
340                        "cert": { "type": "string", "description": "Cert path." }
341                    }
342                }
343            },
344            "properties": {
345                "broker_id": { "type": ["integer", "null"], "format": "int32" },
346                "rack": { "type": ["string", "null"], "default": null, "description": "Rack id." },
347                "tls": {
348                    "anyOf": [ { "$ref": "#/$defs/Tls" }, { "type": "null" } ],
349                    "description": "TLS material."
350                },
351                "listeners": { "type": "array", "items": { "$ref": "#/$defs/Tls" } }
352            }
353        });
354        let md = render_field_table(&schema);
355        for needle in [
356            // type-array collapses to its non-null member
357            "| `broker_id` | integer | no |",
358            "| `rack` | string | no |",
359            // anyOf [$ref, null] resolves to the referenced object and recurses
360            "| `tls` | object | no |",
361            "| `tls.cert` | string | yes |",
362            // array of $ref renders as an object-element array
363            "| `listeners` | array<object> | no |",
364        ] {
365            assert2::assert!(md.contains(needle));
366        }
367    }
368
369    /// A self-referential `$def` is a property that `$ref`s back to its own
370    /// def. It must not recurse forever and it must not overflow the stack. The
371    /// cap limits the work, and the renderer still emits the top-level field
372    /// row.
373    #[test]
374    fn caps_recursion_on_cyclic_ref() {
375        let schema = json!({
376            "type": "object",
377            "$defs": {
378                "Node": {
379                    "type": "object",
380                    "properties": {
381                        // self-reference back into its own def
382                        "child": { "$ref": "#/$defs/Node" },
383                        "label": { "type": "string", "description": "Node label." }
384                    }
385                }
386            },
387            "properties": {
388                "root": { "$ref": "#/$defs/Node" }
389            }
390        });
391        // Returns (doesn't hang/overflow) and emits the top-level field row.
392        let md = render_field_table(&schema);
393        assert2::assert!(md.contains("| `root` | object | no |"));
394        // It recurses some, but the cap keeps the path length bounded.
395        let deepest = md
396            .lines()
397            .filter_map(|l| l.split('`').nth(1))
398            .map(|path| path.matches('.').count())
399            .max()
400            .unwrap_or(0);
401        assert2::assert!(deepest <= MAX_DEPTH);
402    }
403
404    fn check_escaped_mode_row(md: &str) {
405        let row = md.lines().find(|l| l.contains("`mode`")).expect("mode row");
406        // Pipes in the description are escaped...
407        assert2::assert!(row.contains("a \\| b \\| c"));
408        // ...so the row keeps exactly the 5 columns (6 unescaped delimiters).
409        let unescaped_bars = row
410            .match_indices('|')
411            .filter(|(i, _)| *i == 0 || row.as_bytes()[i - 1] != b'\\')
412            .count();
413        assert2::assert!(unescaped_bars == 6);
414    }
415
416    #[test]
417    fn escapes_pipe_in_description() {
418        let schema = json!({
419            "type": "object",
420            "properties": {
421                "mode": {
422                    "type": "string",
423                    "description": "one of: a | b | c"
424                }
425            }
426        });
427        check_escaped_mode_row(&render_field_table(&schema));
428    }
429
430    #[test]
431    fn sectioned_table_emits_headings_and_separators() {
432        let schema = json!({
433            "type": "object",
434            "required": ["broker_id"],
435            "$defs": {
436                "Tls": {
437                    "type": "object",
438                    "description": "TLS material for the listener.",
439                    "required": ["cert"],
440                    "properties": {
441                        "cert": { "type": "string", "description": "Cert path." },
442                        "key": { "type": "string", "description": "Key path." }
443                    }
444                }
445            },
446            "properties": {
447                "broker_id": { "type": "integer", "description": "This broker's id." },
448                "log_dir": { "type": ["string", "null"], "description": "Data directory." },
449                "tls": {
450                    "anyOf": [ { "$ref": "#/$defs/Tls" }, { "type": "null" } ],
451                    "description": "Server TLS config."
452                }
453            }
454        });
455        let md = render_sectioned_field_table(&schema);
456        // Scalars are grouped under a single General section...
457        check!(md.contains("## General"), "{md}");
458        check!(md.contains("| `broker_id` | integer | yes |"), "{md}");
459        check!(md.contains("| `log_dir` | string | no |"), "{md}");
460        // ...objects get their own captioned section with the field's blurb...
461        check!(md.contains("## tls"), "{md}");
462        check!(md.contains("Server TLS config."), "{md}");
463        // ...whose rows are relative to the subtree (no leading `tls.` prefix).
464        check!(md.contains("| `cert` | string | yes |"), "{md}");
465        check!(!md.contains("`tls.cert`"), "{md}");
466        // Sections are separated by a horizontal rule.
467        check!(md.contains("\n---\n"), "{md}");
468    }
469
470    #[test]
471    fn sectioned_table_escapes_pipes_in_section_rows() {
472        let schema = json!({
473            "type": "object",
474            "$defs": {
475                "Inner": {
476                    "type": "object",
477                    "properties": {
478                        "mode": { "type": "string", "description": "one of: a | b | c" }
479                    }
480                }
481            },
482            "properties": {
483                "section": { "$ref": "#/$defs/Inner" }
484            }
485        });
486        check_escaped_mode_row(&render_sectioned_field_table(&schema));
487    }
488}