Commands: a schema with no required parameter allows null params

The engine reads null params as {}, but engine.commands listed every
schema with the root type "object", so a client that validated a call
against the schema refused one the engine accepts. A schema with no
required parameter now has the root type ["object", "null"]. A schema
with a required parameter keeps "object", because the engine refuses
null params for it as a missing parameter. Nested objects are unchanged.
This commit is contained in:
dotCooCoo
2026-11-09 14:56:42 -08:00
parent 93f75def04
commit d186d73c42
4 changed files with 44 additions and 5 deletions
+7 -3
View File
@@ -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());
+22
View File
@@ -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(&params).is_ok(), conformance::instance_valid(&written, &params), "{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");
+12 -1
View File
@@ -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() {
+3 -1
View File
@@ -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.