diff --git a/crates/apischema/src/lib.rs b/crates/apischema/src/lib.rs index cb8f75e5c..5db24230e 100644 --- a/crates/apischema/src/lib.rs +++ b/crates/apischema/src/lib.rs @@ -273,11 +273,15 @@ impl ParamSchema { /// The schema as a JSON Schema 2020-12 object schema. Defaults written as JSON go in /// `default`, a parameter that accepts null as omitted is an `anyOf` with `{"type": "null"}`, /// and VectorCraft's own fields are `x-since`, `x-deprecatedSince`, `x-lenient`, `x-unit`, - /// `x-kind`, `x-cost`, `x-writes` and `x-returns`. A JSON Schema validator accepts the values - /// [`ParamSchema::validate`] accepts, except that an `x-lenient` parameter's schema states only - /// the documented type. + /// `x-kind`, `x-cost`, `x-writes` and `x-returns`. The root `type` is `["object", "null"]` when + /// no parameter is required, because null params read as `{}`, and `"object"` otherwise. A JSON + /// Schema validator accepts the values [`ParamSchema::validate`] accepts, except that an + /// `x-lenient` parameter's schema states only the documented type. pub fn to_json(&self) -> Value { let mut o = object_schema(self.params, 0); + if !self.params.iter().any(|p| p.required) { + o.insert("type".into(), json!(["object", "null"])); + } o.insert("$schema".into(), DIALECT.into()); if !self.summary.is_empty() { o.insert("description".into(), self.summary.into()); diff --git a/crates/apischema/src/tests.rs b/crates/apischema/src/tests.rs index 74a246fc8..30b939879 100644 --- a/crates/apischema/src/tests.rs +++ b/crates/apischema/src/tests.rs @@ -501,6 +501,28 @@ fn the_json_form_is_an_object_schema() { assert_eq!(range["properties"]["x"], json!({"type": "number", "exclusiveMinimum": 0.0, "maximum": 1.0, "x-since": 1})); } +/// Null params read as `{}`, so the root of the JSON form allows null exactly when `{}` passes. +/// A nested object does not read null as `{}`. +#[test] +fn the_json_form_allows_null_params_when_no_parameter_is_required() { + const OPTIONAL: &[Param] = &[Param::new("inner", Type::Object(&[Param::new("y", Type::Boolean).optional()])).optional()]; + let optional = ParamSchema::new(OPTIONAL, ""); + let written = optional.to_json(); + conformance::check_schema(&written, "#").unwrap(); + assert_eq!(written["type"], json!(["object", "null"])); + assert_eq!(written["properties"]["inner"]["type"], "object"); + let required = one(Type::Boolean); + assert_eq!(required.to_json()["type"], "object"); + for schema in [optional, required] { + let written = schema.to_json(); + for params in [json!(null), json!({}), json!(5), json!([]), json!("x"), json!({"inner": null}), json!({"inner": {}})] { + assert_eq!(schema.validate(¶ms).is_ok(), conformance::instance_valid(&written, ¶ms), "{params} against {written}"); + } + } + assert!(optional.validate(&json!(null)).is_ok()); + assert!(required.validate(&json!(null)).is_err()); +} + #[test] fn the_hash_follows_every_schema_but_not_their_order() { let a = ParamSchema::new(VIEW, "save a view"); diff --git a/crates/engine/src/schemas.rs b/crates/engine/src/schemas.rs index 8a203530a..c70c4bb82 100644 --- a/crates/engine/src/schemas.rs +++ b/crates/engine/src/schemas.rs @@ -29,7 +29,7 @@ use crate::{EngineError, Result}; pub const API_VERSION: u32 = 1; /// [`schema_hash`] at each API level, oldest first; the last row is [`API_VERSION`]'s. -pub const SCHEMA_HASHES: &[(u32, u64)] = &[(1, 0xda5b_4c1e_fe4e_c697)]; +pub const SCHEMA_HASHES: &[(u32, u64)] = &[(1, 0x9cdb_1087_7074_855c)]; /// The hash of every built-in command's schema ([`vectorcraft_apischema::hash`]). pub fn schema_hash() -> u64 { @@ -151,6 +151,8 @@ mod tests { assert!(e.contains("view.saved.new") && e.contains("`zoom` must be a number > 0, not -1"), "{e}"); let e = s.execute("view.saved.new", &json!({"center": [0, 0], "zoom": 1, "rotaton": 5})).unwrap_err().to_string(); assert!(e.contains("unknown parameter `rotaton`; did you mean `rotation`?"), "{e}"); + let e = s.execute("view.saved.new", &Value::Null).unwrap_err().to_string(); + assert!(e.contains("view.saved.new") && e.contains("missing `center`"), "{e}"); assert!(s.doc().unwrap().doc.views.is_empty()); assert!(s.doc().unwrap().history.undo.is_empty()); } @@ -285,6 +287,15 @@ mod tests { ("view.transparencyGrid", json!({"on": 1})), ("view.snapToGlyph.options", json!({"bounds": false, "angular": true})), ("view.snapToGlyph.options", json!({"bounds": null})), + ("view.capsLock", json!(null)), + ("view.saved.new", json!(null)), + ("view.saved.edit", json!(null)), + ("view.saved.list", json!(null)), + ("view.transparencyGrid", json!(null)), + ("view.snapToGlyph.options", json!(null)), + ("view.capsLock", json!([])), + ("view.saved.new", json!(5)), + ("view.transparencyGrid", json!("on")), ]; assert_eq!(schemas().len(), 6); for (id, schema) in schemas() { diff --git a/docs/mcp.md b/docs/mcp.md index 1306d484d..5cb33934c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1392,7 +1392,9 @@ Some commands have a machine-readable parameter schema: at present `view.capsLoc fields are `x-since` (the API level that added the command or the parameter), `x-deprecatedSince`, `x-lenient`, `x-unit`, `x-kind` (`id`, `color` or `point`), `x-default` (a default the command works out, such as `toggle`), `x-cost`, `x-writes` (`nothing`, `session`, `preferences` or `document`) and `x-returns`. A parameter that reads null -as left out is an `anyOf` with `{"type": "null"}`. A number with no fractional part, such as `2.0`, is an integer, as +as left out is an `anyOf` with `{"type": "null"}`. Null params read as `{}`, so a schema with no required parameter has +the root `type` `["object", "null"]`. A schema with a required parameter has the root `type` `"object"`, and the engine +refuses null params for it as a missing parameter. A number with no fractional part, such as `2.0`, is an integer, as JSON Schema defines one, and the command receives `2`. An `x-lenient` parameter's schema states the documented type, and the engine also passes a value of another type to the command. The command's `params` text is written from the same schema.