Skip to main content

majordomus_cli/capability/
schema.rs

1//! One schema per type, and every projection reads it.
2//!
3//! A [`CanonicalSchema`] is a JSON Schema (draft 2020-12, as schemars emits it) derived
4//! from the Rust type that the handler actually deserialises and serialises. That is the
5//! whole architectural point: MCP's `inputSchema`, the OpenAPI operation's parameters and
6//! its `components/schemas` entry, and the Cockpit runner's form are four *renderings* of
7//! this value, produced by [`CanonicalSchema::for_mcp`] and
8//! [`CanonicalSchema::for_openapi`]. None of them describes a payload of its own, so none
9//! of them can describe it differently from the code that reads it.
10//!
11//! # The two dialects
12//!
13//! MCP takes the schema inline. OpenAPI wants named components and `$ref`s, so
14//! [`CanonicalSchema::for_openapi`] hoists the type into a `components` map under the
15//! name schemars gave it and answers with the reference — which is why the component
16//! namespace is flat and why two types with one title are a build-time conflict rather
17//! than a document that silently describes one of them twice.
18//!
19//! # One shape under two names
20//!
21//! A `#[serde(transparent)]` newtype — a module's input named for its operation over a
22//! domain type named for the domain — has a schema whose root is a `$ref` into its own
23//! `$defs`. [`CanonicalSchema::resolved`] follows that reference, and every projection
24//! that binds properties reads it through [`CanonicalSchema::properties`], so the wrapper
25//! names the component and the inner type supplies the shape.
26//!
27//! # Query strings
28//!
29//! An HTTP `GET` binds each top-level property from the query string, where everything is
30//! a string. [`coerce`] is the one place that turns `"12"` into a number and `"true"` into
31//! a boolean, guided by the property's own schema, so a route cannot invent a coercion the
32//! schema does not describe.
33//!
34//! ```
35//! use majordomus_cli::capability::CanonicalSchema;
36//! use schemars::JsonSchema;
37//!
38//! /// A search over the index.
39//! #[derive(JsonSchema)]
40//! struct SearchInput {
41//!     /// What to look for.
42//!     query: String,
43//!     /// How many hits at most.
44//!     limit: u32,
45//! }
46//!
47//! let schema = CanonicalSchema::of::<SearchInput>();
48//! assert_eq!(schema.name.as_deref(), Some("SearchInput"));
49//!
50//! // the properties every projection binds, derived once
51//! let (properties, required) = schema.properties();
52//! let names: Vec<&str> = properties.iter().map(|(n, _)| n.as_str()).collect();
53//! assert!(names.contains(&"query") && names.contains(&"limit"));
54//! assert!(required.contains(&"query".to_string()));
55//!
56//! // MCP takes it inline; OpenAPI takes a reference and the component beside it
57//! assert!(schema.for_mcp()["properties"]["query"].is_object());
58//! let mut components = std::collections::BTreeMap::new();
59//! let reference = schema.openapi_ref(&mut components).unwrap();
60//! assert_eq!(reference["$ref"], "#/components/schemas/SearchInput");
61//! assert!(components.contains_key("SearchInput"));
62//! ```
63
64use std::borrow::Cow;
65use std::collections::BTreeMap;
66
67use schemars::JsonSchema;
68use serde::{Deserialize, Serialize};
69use serde_json::Value;
70
71/// A JSON Schema with, when the type has one, a stable component name (the type's title).
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
73pub struct CanonicalSchema {
74    /// The component name projections use (`RepositoryInfo`); `None` for an anonymous
75    /// schema such as the empty input.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub name: Option<String>,
78    /// The schema itself, without `$schema`; nested types live under `$defs`.
79    pub schema: Value,
80}
81
82impl CanonicalSchema {
83    /// The schema of a Rust type.
84    ///
85    /// ```
86    /// use majordomus_cli::capability::CanonicalSchema;
87    /// use schemars::JsonSchema;
88    ///
89    /// /// The thing.
90    /// #[derive(JsonSchema)]
91    /// struct Thing {
92    ///     /// A name.
93    ///     name: String,
94    ///     count: Option<u32>,
95    /// }
96    /// let s = CanonicalSchema::of::<Thing>();
97    /// assert_eq!(s.name.as_deref(), Some("Thing"));
98    /// let (props, required) = s.properties();
99    /// assert_eq!(props.iter().map(|(k, _)| k.as_str()).collect::<Vec<_>>(), ["name", "count"]);
100    /// assert_eq!(required, ["name"]);
101    /// ```
102    pub fn of<T: JsonSchema>() -> Self {
103        crate::perf::Counters::bump(&crate::perf::COUNTERS.schema_generations);
104        let mut schema = schemars::schema_for!(T).to_value();
105        let name = schema
106            .get("title")
107            .and_then(Value::as_str)
108            .map(str::to_string);
109        if let Value::Object(m) = &mut schema {
110            m.remove("$schema");
111        }
112        // schemars copies the doc comment into `description` verbatim, and every consumer
113        // of that field — OpenAPI, MCP, Swagger UI, the published site — reads it as
114        // CommonMark. Rustdoc is not CommonMark, so the dialect is translated here, at the
115        // one place a doc comment becomes a projection, rather than by each projection.
116        super::rustdoc::translate_descriptions(&mut schema);
117        CanonicalSchema { name, schema }
118    }
119
120    /// No input: an object with no properties and none allowed.
121    pub fn empty() -> Self {
122        CanonicalSchema {
123            name: None,
124            schema: serde_json::json!({ "type": "object", "properties": {}, "additionalProperties": false }),
125        }
126    }
127
128    /// This schema with a root `$ref` followed into its own `$defs`: the object the type
129    /// describes, rather than the reference that names it.
130    ///
131    /// A `#[serde(transparent)]` newtype is one shape under two names, and that is how
132    /// schemars writes it: the wrapper's title and description at the root, the shape
133    /// under `$defs`, and nothing but a `$ref` between them. Every projection that binds
134    /// *properties* — a `GET` operation's query parameters, the router's coercion of the
135    /// query string, the Cockpit's form, the generated module reference — reads the root,
136    /// and a root that is a reference has no properties. Unfollowed, the document says the
137    /// operation takes nothing while the route still binds every field of the inner
138    /// struct, and the router coerces each one against an empty schema: every integer and
139    /// boolean parameter then arrives as a string and the call is refused as invalid.
140    ///
141    /// The `$defs` travel with the resolved schema, so a nested reference still resolves
142    /// and [`for_openapi`](Self::for_openapi) still hoists every definition.
143    ///
144    /// ```
145    /// use majordomus_cli::capability::CanonicalSchema;
146    /// use schemars::JsonSchema;
147    /// use serde::{Deserialize, Serialize};
148    ///
149    /// /// What to look for.
150    /// #[derive(Serialize, Deserialize, JsonSchema)]
151    /// struct SearchInput {
152    ///     /// The words.
153    ///     query: String,
154    ///     /// How many hits at most.
155    ///     limit: Option<u32>,
156    /// }
157    ///
158    /// /// The input of `fixture.search`.
159    /// #[derive(Serialize, Deserialize, JsonSchema)]
160    /// #[serde(transparent)]
161    /// struct Wrapper(SearchInput);
162    ///
163    /// let schema = CanonicalSchema::of::<Wrapper>();
164    /// // the wrapper names the component, and its root is a reference
165    /// assert_eq!(schema.name.as_deref(), Some("Wrapper"));
166    /// assert_eq!(schema.schema["$ref"], "#/$defs/SearchInput");
167    /// // and the properties every projection binds are the inner type's
168    /// let (properties, required) = schema.properties();
169    /// let names: Vec<&str> = properties.iter().map(|(n, _)| n.as_str()).collect();
170    /// assert_eq!(names, ["query", "limit"]);
171    /// assert_eq!(required, ["query"]);
172    /// // a schema whose root is already the object is its own resolution
173    /// let plain = CanonicalSchema::of::<SearchInput>();
174    /// assert_eq!(plain.resolved().schema, plain.schema);
175    /// ```
176    pub fn resolved(&self) -> Cow<'_, CanonicalSchema> {
177        let target = self
178            .schema
179            .get("$ref")
180            .and_then(Value::as_str)
181            .and_then(|r| r.strip_prefix("#/$defs/"));
182        let definition = target.and_then(|t| {
183            self.schema
184                .get("$defs")
185                .and_then(Value::as_object)
186                .and_then(|defs| defs.get(t))
187                .cloned()
188        });
189        let Some(mut schema) = definition else {
190            return Cow::Borrowed(self);
191        };
192        if let (Value::Object(m), Some(defs)) = (&mut schema, self.schema.get("$defs")) {
193            m.insert("$defs".into(), defs.clone());
194        }
195        Cow::Owned(CanonicalSchema {
196            name: self.name.clone(),
197            schema,
198        })
199    }
200
201    /// Top-level properties with their schemas, and which are required, for a schema that
202    /// describes an object. Order is the schema's. A root `$ref` is followed first, so a
203    /// transparent newtype yields the properties of the type it wraps.
204    pub fn properties(&self) -> (Vec<(String, Value)>, Vec<String>) {
205        let resolved = self.resolved();
206        let props = resolved
207            .schema
208            .get("properties")
209            .and_then(Value::as_object)
210            .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
211            .unwrap_or_default();
212        let required = resolved
213            .schema
214            .get("required")
215            .and_then(Value::as_array)
216            .map(|a| {
217                a.iter()
218                    .filter_map(Value::as_str)
219                    .map(str::to_string)
220                    .collect()
221            })
222            .unwrap_or_default();
223        (props, required)
224    }
225
226    /// The schema as an MCP client expects it: self-contained, `$defs` kept inline.
227    pub fn for_mcp(&self) -> Value {
228        self.schema.clone()
229    }
230
231    /// Split into the top-level schema and its named definitions with every `$ref`
232    /// rewritten to `#/components/schemas/<name>`, for OpenAPI. A definition name that
233    /// is already registered with different content is a conflict, reported by name.
234    pub fn for_openapi(&self, components: &mut BTreeMap<String, Value>) -> Result<Value, String> {
235        let mut top = self.schema.clone();
236        let defs = match &mut top {
237            Value::Object(m) => m.remove("$defs").and_then(|d| match d {
238                Value::Object(d) => Some(d),
239                _ => None,
240            }),
241            _ => None,
242        };
243        rewrite_refs(&mut top);
244        if let Some(defs) = defs {
245            for (name, mut def) in defs {
246                rewrite_refs(&mut def);
247                register(components, name, def)?;
248            }
249        }
250        Ok(top)
251    }
252
253    /// Register this schema under its name and return a `$ref` to it; an anonymous schema
254    /// is returned inline.
255    pub fn openapi_ref(&self, components: &mut BTreeMap<String, Value>) -> Result<Value, String> {
256        let top = self.for_openapi(components)?;
257        match &self.name {
258            Some(name) => {
259                let mut stripped = top;
260                if let Value::Object(m) = &mut stripped {
261                    m.remove("title");
262                }
263                register(components, name.clone(), stripped)?;
264                Ok(serde_json::json!({ "$ref": format!("#/components/schemas/{name}") }))
265            }
266            None => Ok(top),
267        }
268    }
269}
270
271fn register(
272    components: &mut BTreeMap<String, Value>,
273    name: String,
274    def: Value,
275) -> Result<(), String> {
276    match components.get(&name) {
277        Some(existing) if *existing != def => Err(format!(
278            "schema component '{name}' is defined twice with different content"
279        )),
280        Some(_) => Ok(()),
281        None => {
282            components.insert(name, def);
283            Ok(())
284        }
285    }
286}
287
288fn rewrite_refs(v: &mut Value) {
289    match v {
290        Value::Object(m) => {
291            if let Some(Value::String(r)) = m.get_mut("$ref") {
292                if let Some(name) = r.strip_prefix("#/$defs/") {
293                    *r = format!("#/components/schemas/{name}");
294                }
295            }
296            for (_, child) in m.iter_mut() {
297                rewrite_refs(child);
298            }
299        }
300        Value::Array(a) => a.iter_mut().for_each(rewrite_refs),
301        _ => {}
302    }
303}
304
305/// Every type a property schema names, following the one level of `anyOf`/`oneOf` that
306/// schemars writes for an `Option<T>` of a structured type. A property whose type cannot be
307/// read names none, and the caller leaves the value a string.
308fn declared_types(property: &Value) -> Vec<String> {
309    let mut out = Vec::new();
310    let mut collect = |v: Option<&Value>| match v {
311        Some(Value::String(s)) => out.push(s.clone()),
312        Some(Value::Array(a)) => out.extend(a.iter().filter_map(Value::as_str).map(String::from)),
313        _ => {}
314    };
315    collect(property.get("type"));
316    for key in ["anyOf", "oneOf"] {
317        for member in property
318            .get(key)
319            .and_then(Value::as_array)
320            .into_iter()
321            .flatten()
322        {
323            collect(member.get("type"));
324        }
325    }
326    out
327}
328
329/// The JSON type word for a value, for a message a caller can act on.
330fn kind_of(v: &Value) -> &'static str {
331    match v {
332        Value::Null => "null",
333        Value::Bool(_) => "a boolean",
334        Value::Number(_) => "a number",
335        Value::String(_) => "a string",
336        Value::Array(_) => "an array",
337        Value::Object(_) => "an object",
338    }
339}
340
341/// Convert a query-string value to the JSON type a property schema names: integers and
342/// booleans are parsed, a structured parameter is read as the JSON text the binding sent,
343/// and everything else stays a string.
344///
345/// The array and object cases are the exact inverse of
346/// [`Request::bind`](crate::http::Request::bind), which puts a non-scalar on the query
347/// string as JSON. Without them a `GET` route could be *sent* a list and could not *read*
348/// one, which is a projection that only works in one direction — and the generic test that
349/// replays every capability's benchmark cases over the wire is where that showed.
350///
351/// ```
352/// use majordomus_cli::capability::schema::coerce;
353/// use serde_json::json;
354/// assert_eq!(coerce(&json!({ "type": "integer" }), "7").unwrap(), json!(7));
355/// assert_eq!(coerce(&json!({ "type": "string" }), "7").unwrap(), json!("7"));
356/// assert!(coerce(&json!({ "type": "boolean" }), "yes").is_err());
357///
358/// // a list parameter arrives as the JSON the binding wrote, nullable or not
359/// let list = json!({ "type": ["array", "null"], "items": { "type": "string" } });
360/// assert_eq!(coerce(&list, r#"["a","b"]"#).unwrap(), json!(["a", "b"]));
361/// // and a value that is not that list is refused rather than passed on as a string
362/// assert!(coerce(&list, "a,b").is_err());
363/// ```
364pub fn coerce(property: &Value, raw: &str) -> Result<Value, String> {
365    let types = declared_types(property);
366    let types: Vec<&str> = types.iter().map(String::as_str).collect();
367    if types.contains(&"array") || types.contains(&"object") {
368        let parsed: Value = serde_json::from_str(raw)
369            .map_err(|e| format!("'{raw}' is not the JSON this parameter takes: {e}"))?;
370        let fits = (parsed.is_array() && types.contains(&"array"))
371            || (parsed.is_object() && types.contains(&"object"))
372            || (parsed.is_null() && types.contains(&"null"));
373        return if fits {
374            Ok(parsed)
375        } else {
376            Err(format!(
377                "'{raw}' parses as {}, and this parameter takes {}",
378                kind_of(&parsed),
379                types.join(" or ")
380            ))
381        };
382    }
383    if types.contains(&"integer") {
384        return raw
385            .parse::<i64>()
386            .map(Value::from)
387            .map_err(|_| format!("'{raw}' is not an integer"));
388    }
389    if types.contains(&"number") {
390        return raw
391            .parse::<f64>()
392            .map(Value::from)
393            .map_err(|_| format!("'{raw}' is not a number"));
394    }
395    if types.contains(&"boolean") {
396        return match raw {
397            "true" => Ok(Value::Bool(true)),
398            "false" => Ok(Value::Bool(false)),
399            _ => Err(format!("'{raw}' is not a boolean")),
400        };
401    }
402    Ok(Value::String(raw.to_string()))
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[derive(JsonSchema)]
410    #[allow(dead_code)]
411    struct Inner {
412        n: u32,
413    }
414    /// Doc comment becomes the description.
415    #[derive(JsonSchema)]
416    #[allow(dead_code)]
417    struct Outer {
418        /// A name.
419        name: String,
420        inner: Inner,
421        maybe: Option<u64>,
422    }
423
424    #[test]
425    fn schema_of_a_type_has_name_defs_and_properties() {
426        let s = CanonicalSchema::of::<Outer>();
427        assert_eq!(s.name.as_deref(), Some("Outer"));
428        assert!(s.schema.get("$schema").is_none());
429        let (props, required) = s.properties();
430        assert_eq!(
431            props.iter().map(|(k, _)| k.as_str()).collect::<Vec<_>>(),
432            ["name", "inner", "maybe"]
433        );
434        assert_eq!(required, ["name", "inner"]);
435        assert_eq!(
436            s.schema["description"],
437            "Doc comment becomes the description."
438        );
439    }
440
441    #[test]
442    fn openapi_projection_hoists_defs_and_rewrites_refs() {
443        let s = CanonicalSchema::of::<Outer>();
444        let mut components = BTreeMap::new();
445        let r = s.openapi_ref(&mut components).unwrap();
446        assert_eq!(
447            r,
448            serde_json::json!({ "$ref": "#/components/schemas/Outer" })
449        );
450        assert!(components.contains_key("Inner"));
451        assert_eq!(
452            components["Outer"]["properties"]["inner"]["$ref"],
453            "#/components/schemas/Inner"
454        );
455        assert!(components["Outer"].get("$defs").is_none());
456        // the same type twice is fine; a different type with the same name is not
457        assert!(s.openapi_ref(&mut components).is_ok());
458        components.insert("Inner".into(), serde_json::json!({ "type": "string" }));
459        assert!(s.openapi_ref(&mut components).is_err());
460    }
461
462    /// A module's input over a domain type: one shape, the wrapper's name.
463    #[derive(JsonSchema)]
464    #[serde(transparent)]
465    #[allow(dead_code)]
466    struct Wrapped(Outer);
467
468    #[test]
469    fn a_transparent_newtype_projects_the_shape_it_wraps() {
470        let s = CanonicalSchema::of::<Wrapped>();
471        // the root is a reference, and the name is still the wrapper's
472        assert_eq!(s.name.as_deref(), Some("Wrapped"));
473        assert_eq!(s.schema["$ref"], "#/$defs/Outer");
474        // the properties a projection binds are the wrapped type's, not none
475        let (props, required) = s.properties();
476        assert_eq!(
477            props.iter().map(|(k, _)| k.as_str()).collect::<Vec<_>>(),
478            ["name", "inner", "maybe"]
479        );
480        assert_eq!(required, ["name", "inner"]);
481        // and a query parameter carries the wrapped type's own schema, so the router
482        // coerces `maybe` to a number rather than leaving it a string
483        assert_eq!(
484            props.iter().find(|(k, _)| k == "maybe").unwrap().1["type"],
485            serde_json::json!(["integer", "null"])
486        );
487        // the OpenAPI projection of the resolved schema is the object, with every nested
488        // definition still hoisted
489        let mut components = BTreeMap::new();
490        let top = s.resolved().for_openapi(&mut components).unwrap();
491        assert!(top["properties"]["name"].is_object());
492        assert_eq!(
493            top["properties"]["inner"]["$ref"],
494            "#/components/schemas/Inner"
495        );
496        assert!(components.contains_key("Inner") && components.contains_key("Outer"));
497        // the unresolved schema is still what names the component, for a request body
498        let mut components = BTreeMap::new();
499        assert_eq!(
500            s.openapi_ref(&mut components).unwrap(),
501            serde_json::json!({ "$ref": "#/components/schemas/Wrapped" })
502        );
503    }
504
505    #[test]
506    fn coercion_follows_the_property_type() {
507        assert_eq!(
508            coerce(&serde_json::json!({ "type": "integer" }), "7").unwrap(),
509            7
510        );
511        assert!(coerce(&serde_json::json!({ "type": "integer" }), "x").is_err());
512        assert_eq!(
513            coerce(&serde_json::json!({ "type": ["integer", "null"] }), "7").unwrap(),
514            7
515        );
516        assert_eq!(
517            coerce(&serde_json::json!({ "type": "boolean" }), "true").unwrap(),
518            true
519        );
520        assert_eq!(
521            coerce(&serde_json::json!({ "type": "string" }), "7").unwrap(),
522            "7"
523        );
524    }
525}