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}