Merge pull request 'schemagen: reject absent additionalProperties' (#58) from weaselbot/weaseljson:weaselbot/issue-55 into main
CI / pre-commit (push) Successful in 53s
CI / build (-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++, clang-arm64, ubuntu-latest-arm64, true) (push) Successful in 53s
CI / build (-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++, gcc-arm64, ubuntu-latest-arm64, false) (push) Successful in 50s
CI / build (-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++, clang-amd64, ubuntu-latest-amd64, true) (push) Successful in 1m36s
CI / build (-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++, gcc-amd64, ubuntu-latest-amd64, false) (push) Successful in 1m34s

Reviewed-on: #58
This commit was merged in pull request #58.
This commit is contained in:
2026-07-20 18:06:53 +00:00
4 changed files with 55 additions and 15 deletions
+3 -2
View File
@@ -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).
+1
View File
@@ -56,6 +56,7 @@
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"name"
],
+44 -12
View File
@@ -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"}},
}
},
@@ -680,6 +698,7 @@ class SchemagenIntegerBoundaryTest(unittest.TestCase):
)
schema = {
"type": "object",
"additionalProperties": False,
"properties": {"age": {"type": "integer"}},
"required": ["age"],
}
@@ -795,14 +814,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)
@@ -810,6 +837,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"]},
},
@@ -822,7 +850,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)
+7 -1
View File
@@ -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):