khive_runtime/input_schema.rs
1//! Derive a JSON Schema for a verb's parameters from its own [`ParamDef`] list.
2//!
3//! `help` publishes `params[].type` as a documentation string. Every machine
4//! bridge parses it as a JSON Schema type, and several of the spellings in use
5//! are not schema types: `uuid` names a format, `array` names no element type,
6//! and `object or array of object` is a union. A driver that cannot read a
7//! verb's parameter types cannot call it, and only the git pack publishes a
8//! hand-authored `input_schema` to read instead.
9//!
10//! The duplicate spellings this module used to absorb are gone: one type is
11//! written one way, and the registry-wide test in `kkernel` asserts the exact
12//! set. The map below therefore has one arm per spelling, and adding an
13//! alternation to an arm is the shape to refuse, because it lets a second
14//! spelling of an existing type back in without anything failing.
15//!
16//! This module closes that by deriving a schema from the declarations the
17//! runtime already holds. A pack-supplied `input_schema` still wins, so the
18//! hand-authored ones are untouched; every other verb gains a derived one.
19//! `params[].type` is left exactly as it is: it is documentation, and `uuid`
20//! carries information a bare schema type does not.
21
22use serde_json::{json, Value};
23
24use khive_types::ParamDef;
25
26/// Parameters every verb accepts without declaring them, and which therefore
27/// must not be rejected by a derived schema.
28///
29/// `help` short-circuits any dispatch to the verb's own description, and
30/// `namespace` is resolved for every verb by the dispatch path whether or not
31/// the verb lists it. A schema stricter than the dispatcher turns a working
32/// call into a driver-side refusal, which is the same defect this module exists
33/// to fix pointed the other way, so `additionalProperties` stays open and these
34/// two are declared rather than merely tolerated.
35const UNIVERSAL_PARAMS: &[(&str, &str, &str)] = &[
36 (
37 "help",
38 "boolean",
39 "Return this verb's description instead of dispatching it. No side effects.",
40 ),
41 (
42 "namespace",
43 "string",
44 "Attribution namespace for this call. Defaults to the caller's resolved namespace.",
45 ),
46];
47
48/// Map one declared `param_type` spelling onto a JSON Schema fragment.
49///
50/// Total over the spellings in use, and deliberately without a wildcard arm: an
51/// unknown spelling returns `None` so the registry-wide test can name it and
52/// fail. A default arm here would silently give every future spelling whatever
53/// the fallback happened to be, which recreates the defect one verb at a time.
54pub fn json_schema_type(param_type: &str) -> Option<Value> {
55 let schema = match param_type {
56 "string" => json!({"type": "string"}),
57 "integer" => json!({"type": "integer"}),
58 "number" => json!({"type": "number"}),
59 "boolean" => json!({"type": "boolean"}),
60 "object" => json!({"type": "object"}),
61 "array" => json!({"type": "array"}),
62 "uuid" => json!({"type": "string", "format": "uuid"}),
63 "array of string" => {
64 json!({"type": "array", "items": {"type": "string"}})
65 }
66 "array of object" => {
67 json!({"type": "array", "items": {"type": "object"}})
68 }
69 "array of uuid" => {
70 json!({"type": "array", "items": {"type": "string", "format": "uuid"}})
71 }
72 "object or array of object" => json!({
73 "oneOf": [{"type": "object"}, {"type": "array", "items": {"type": "object"}}]
74 }),
75 "string | array<string>" => json!({
76 "oneOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}]
77 }),
78 "string|null" => json!({"type": ["string", "null"]}),
79 // A parameter documented as accepting any JSON. The empty schema says
80 // exactly that, rather than picking one type and being wrong.
81 "JSON value" => json!({}),
82 _ => return None,
83 };
84 Some(schema)
85}
86
87/// Build the `input_schema` object for one verb's parameter list.
88///
89/// Returns `None` when any declared spelling has no mapping, so a verb is never
90/// published with a schema that quietly omits one of its parameters. A partial
91/// schema is worse than none: a driver would read it as complete.
92pub fn derive_input_schema(params: &[ParamDef], described: &[(String, String)]) -> Option<Value> {
93 let mut properties = serde_json::Map::new();
94 let mut required: Vec<Value> = Vec::new();
95
96 for (index, param) in params.iter().enumerate() {
97 let mut schema = json_schema_type(param.param_type)?;
98 let description = described
99 .get(index)
100 .map(|(_, d)| d.clone())
101 .unwrap_or_else(|| param.description.to_string());
102 if let Value::Object(ref mut map) = schema {
103 map.insert("description".to_string(), json!(description));
104 }
105 properties.insert(param.name.to_string(), schema);
106 if param.required {
107 required.push(json!(param.name));
108 }
109 }
110
111 for (name, spelling, description) in UNIVERSAL_PARAMS {
112 if properties.contains_key(*name) {
113 continue;
114 }
115 let mut schema = json_schema_type(spelling)?;
116 if let Value::Object(ref mut map) = schema {
117 map.insert("description".to_string(), json!(description));
118 }
119 properties.insert((*name).to_string(), schema);
120 }
121
122 Some(json!({
123 "type": "object",
124 "properties": Value::Object(properties),
125 "required": Value::Array(required),
126 "additionalProperties": true,
127 }))
128}