diff --git a/contrib/schemagen/README.md b/contrib/schemagen/README.md index 98f2686..53b060d 100644 --- a/contrib/schemagen/README.md +++ b/contrib/schemagen/README.md @@ -60,8 +60,9 @@ into the result, so it is non-movable. ## Not supported (rejected at generation time, no fallback) `oneOf` / `anyOf` / `allOf` / `not` / `if`-`then`-`else`, -`patternProperties`, `additionalProperties` with a schema (typed map), -`additionalProperties: true`, `prefixItems` (tuples), `const`, +`patternProperties`, `additionalProperties` absent or set to `true`, +`additionalProperties` with a schema (typed map), `prefixItems` (tuples), +`const`, `dependentSchemas`/`dependentRequired`, union `type` lists other than `["T", "null"]`, non-string enums, and remote (`$ref` to other documents). diff --git a/contrib/schemagen/example.schema.json b/contrib/schemagen/example.schema.json index e1c0f6b..20f786b 100644 --- a/contrib/schemagen/example.schema.json +++ b/contrib/schemagen/example.schema.json @@ -56,6 +56,7 @@ "type": "array", "items": { "type": "object", + "additionalProperties": false, "required": [ "name" ], diff --git a/contrib/schemagen/test_schemagen.py b/contrib/schemagen/test_schemagen.py index a3e4425..e4c9b60 100644 --- a/contrib/schemagen/test_schemagen.py +++ b/contrib/schemagen/test_schemagen.py @@ -32,6 +32,7 @@ class SchemagenEnumTest(unittest.TestCase): def test_colliding_enum_values_deduplicate(self): schema = { "type": "object", + "additionalProperties": False, "properties": {"role": {"enum": ["foo-bar", "foo_bar"]}}, } rc, stdout, stderr = self.run_schemagen(schema) @@ -45,6 +46,7 @@ class SchemagenEnumTest(unittest.TestCase): def test_distinct_enum_values_generate(self): schema = { "type": "object", + "additionalProperties": False, "properties": {"role": {"enum": ["admin", "user", "guest"]}}, } rc, stdout, stderr = self.run_schemagen(schema) @@ -80,7 +82,7 @@ class SchemagenKeywordTest(unittest.TestCase): "module", "import", ] - schema = {"type": "object", "properties": {}} + schema = {"type": "object", "additionalProperties": False, "properties": {}} for kw in keywords: schema["properties"][kw] = {"type": "string"} rc, stdout, stderr = self.run_schemagen(schema) @@ -164,11 +166,18 @@ class SchemagenCollisionTest(unittest.TestCase): def test_kind_enum_does_not_duplicate_arr0(self): schema = { "type": "object", + "additionalProperties": False, "properties": { "arr": {"type": "array", "items": {"type": "string"}}, "obj": {"$ref": "#/$defs/Arr0"}, }, - "$defs": {"Arr0": {"type": "object", "properties": {}}}, + "$defs": { + "Arr0": { + "type": "object", + "additionalProperties": False, + "properties": {}, + } + }, } out = self.generate_and_compile(schema) self.assertIn("struct Arr0", out) @@ -183,7 +192,13 @@ class SchemagenCollisionTest(unittest.TestCase): schema = { "type": "array", "items": {"$ref": "#/$defs/Skip"}, - "$defs": {"Skip": {"type": "object", "properties": {}}}, + "$defs": { + "Skip": { + "type": "object", + "additionalProperties": False, + "properties": {}, + } + }, } out = self.generate_and_compile(schema) self.assertIn("struct Skip", out) @@ -199,6 +214,7 @@ class SchemagenCollisionTest(unittest.TestCase): """Regression test for issue #17: enum and array $defs must be reused.""" schema = { "type": "object", + "additionalProperties": False, "properties": { "role1": {"$ref": "#/$defs/Role"}, "role2": {"$ref": "#/$defs/Role"}, @@ -252,17 +268,14 @@ class SchemagenAdditionalPropertiesTest(unittest.TestCase): self.assertNotEqual(rc, 0) self.assertIn("additionalProperties: true is not supported", stderr) - def test_additional_properties_absent_defaults_to_strict(self): + def test_additional_properties_absent_rejected(self): schema = { "type": "object", "properties": {"name": {"type": "string"}}, } rc, stdout, stderr = self.run_schemagen(schema) - self.assertEqual(rc, 0, msg=stderr) - # The generated parser should reject unknown keys. Verify the key-matching - # helper returns -1 for an unknown key and cbKeyData rejects it. - self.assertIn("int matchKey(Kind k, std::string_view key) const {", stdout) - self.assertNotIn("bool isStrict(Kind k) const", stdout) + self.assertNotEqual(rc, 0) + self.assertIn("additionalProperties is required for object schemas", stderr) def test_additional_properties_false_accepted(self): schema = { @@ -310,6 +323,7 @@ class SchemagenCyclicArrayTest(unittest.TestCase): def test_object_field_to_self_referential_array_rejected(self): schema = { "type": "object", + "additionalProperties": False, "properties": {"items": {"$ref": "#/$defs/Items"}}, "$defs": { "Items": { @@ -326,6 +340,7 @@ class SchemagenCyclicArrayTest(unittest.TestCase): def test_chain_of_array_refs_rejected(self): schema = { "type": "object", + "additionalProperties": False, "properties": {"x": {"$ref": "#/$defs/A"}}, "$defs": { "A": {"type": "array", "items": {"$ref": "#/$defs/B"}}, @@ -339,6 +354,7 @@ class SchemagenCyclicArrayTest(unittest.TestCase): def test_non_recursive_array_refs_still_allowed(self): schema = { "type": "object", + "additionalProperties": False, "properties": {"roles": {"$ref": "#/$defs/Roles"}}, "$defs": { "Role": {"enum": ["admin", "user"]}, @@ -439,10 +455,12 @@ class SchemagenCyclicNullableObjectTest(unittest.TestCase): self.skipTest("C++ compiler not available") schema = { "type": "object", + "additionalProperties": False, "properties": {"self": {"$ref": "#/$defs/Self"}}, "$defs": { "Self": { "type": ["object", "null"], + "additionalProperties": False, "properties": {"self": {"$ref": "#/$defs/Self"}}, } }, @@ -670,6 +688,7 @@ class SchemagenIntegerBoundaryTest(unittest.TestCase): ) schema = { "type": "object", + "additionalProperties": False, "properties": {"age": {"type": "integer"}}, "required": ["age"], } @@ -785,14 +804,22 @@ class SchemagenStringEscapeTest(unittest.TestCase): def test_newline_in_property_key(self): """A JSON key containing a newline must become a valid C++ literal.""" - schema = {"type": "object", "properties": {"a\nb": {"type": "string"}}} + schema = { + "type": "object", + "additionalProperties": False, + "properties": {"a\nb": {"type": "string"}}, + } out = self.generate_and_compile(schema) self.assertIn('// "a\\nb"', out) self.assertIn('if (key == "a\\nb") return 0;', out) def test_newline_in_enum_value(self): """An enum value containing a newline must become a valid C++ literal.""" - schema = {"type": "object", "properties": {"x": {"enum": ["a\nb"]}}} + schema = { + "type": "object", + "additionalProperties": False, + "properties": {"x": {"enum": ["a\nb"]}}, + } out = self.generate_and_compile(schema) self.assertIn('static constexpr const char *X_names[] = { "a\\nb" };', out) @@ -800,6 +827,7 @@ class SchemagenStringEscapeTest(unittest.TestCase): """Mixed control characters in an enum value must be escaped.""" schema = { "type": "object", + "additionalProperties": False, "properties": { "x": {"enum": ["x\ny\rz\tw\vq\x00\x01"]}, }, @@ -812,7 +840,11 @@ class SchemagenStringEscapeTest(unittest.TestCase): def test_backslash_and_quote_still_escaped(self): """Existing escaping for backslash and double quote must remain correct.""" - schema = {"type": "object", "properties": {'a"b\\c': {"type": "string"}}} + schema = { + "type": "object", + "additionalProperties": False, + "properties": {'a"b\\c': {"type": "string"}}, + } out = self.generate_and_compile(schema) self.assertIn('// "a\\"b\\\\c"', out) self.assertIn('if (key == "a\\"b\\\\c") return 0;', out) diff --git a/contrib/schemagen/weaseljson_schemagen.py b/contrib/schemagen/weaseljson_schemagen.py index 2283454..ca08140 100644 --- a/contrib/schemagen/weaseljson_schemagen.py +++ b/contrib/schemagen/weaseljson_schemagen.py @@ -417,7 +417,13 @@ class Builder: tobj = TObj(name) if defname is not None: self._building[defname] = (tobj, nullable) - ap = node.get("additionalProperties", False) + ap = node.get("additionalProperties", None) + if ap is None: + raise GenError( + "additionalProperties is required for object schemas; set it " + "explicitly to false to reject unknown keys (absent " + "additionalProperties is not supported)" + ) if ap is True: raise GenError("additionalProperties: true is not supported") if isinstance(ap, dict):