schemagen: reject absent additionalProperties

Per the README contract, an object schema without an explicit
`additionalProperties` was documented as rejected at generation time,
but the code defaulted it to `false` and silently generated a strict
parser that rejects valid documents with extra properties.

Make the code match the documented contract by raising GenError when
`additionalProperties` is absent, with a clear message instructing the
author to set it explicitly to false. Update the README prose section
to list absent additionalProperties alongside true, and fix existing
schemas and tests to declare additionalProperties explicitly.

Closes #55
This commit is contained in:
2026-07-19 22:06:23 -04:00
parent 16f5d3cd9d
commit 86b58e83cb
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"}},
}
},
@@ -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)
+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):