forked from weaselab/weaseljson
Compare commits
5
Commits
cdff634057
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
730a4cd0f6 | ||
|
|
fd34603ac0 | ||
|
|
c44b0262bc | ||
|
|
814b24efba | ||
|
|
86b58e83cb |
@@ -60,8 +60,10 @@ 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`, `required` entries with no matching `properties` key (the schema
|
||||
would be unsatisfiable),
|
||||
`dependentSchemas`/`dependentRequired`, union `type` lists other than
|
||||
`["T", "null"]`, non-string enums, and remote (`$ref` to other documents).
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
|
||||
@@ -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 = {
|
||||
@@ -274,6 +287,94 @@ class SchemagenAdditionalPropertiesTest(unittest.TestCase):
|
||||
self.assertEqual(rc, 0, msg=stderr)
|
||||
|
||||
|
||||
class SchemagenRequiredPropertyTest(unittest.TestCase):
|
||||
"""Regression tests for issue #63: `required` entries with no matching
|
||||
`properties` key must be rejected at generation time. With the mandatory
|
||||
`additionalProperties: false` the schema is unsatisfiable, and the
|
||||
generated parser has no seen bit for such keys, so it would otherwise
|
||||
silently accept documents missing the required key."""
|
||||
|
||||
def run_schemagen(self, schema, args=None):
|
||||
"""Run schemagen on a schema dict. Returns (returncode, stdout, stderr)."""
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fp:
|
||||
json.dump(schema, fp)
|
||||
schema_path = fp.name
|
||||
try:
|
||||
cmd = [sys.executable, SCRIPT, schema_path]
|
||||
if args:
|
||||
cmd.extend(args)
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
finally:
|
||||
os.unlink(schema_path)
|
||||
|
||||
def test_required_key_without_properties_entry_rejected(self):
|
||||
schema = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {"a": {"type": "integer"}},
|
||||
"required": ["b"],
|
||||
}
|
||||
rc, stdout, stderr = self.run_schemagen(schema)
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("required key 'b' has no matching properties entry", stderr)
|
||||
|
||||
def test_multiple_dangling_required_keys_reported(self):
|
||||
schema = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {"a": {"type": "integer"}},
|
||||
"required": ["a", "x", "y"],
|
||||
}
|
||||
rc, stdout, stderr = self.run_schemagen(schema)
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn(
|
||||
"required keys 'x', 'y' have no matching properties entries",
|
||||
stderr,
|
||||
)
|
||||
|
||||
def test_required_without_properties_rejected(self):
|
||||
schema = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"required": ["any"],
|
||||
}
|
||||
rc, stdout, stderr = self.run_schemagen(schema)
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("required key 'any' has no matching properties entry", stderr)
|
||||
|
||||
def test_nested_required_key_without_properties_entry_rejected(self):
|
||||
schema = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {"child": {"$ref": "#/$defs/Child"}},
|
||||
"$defs": {
|
||||
"Child": {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {"x": {"type": "string"}},
|
||||
"required": ["misspelled"],
|
||||
}
|
||||
},
|
||||
}
|
||||
rc, stdout, stderr = self.run_schemagen(schema)
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn(
|
||||
"required key 'misspelled' has no matching properties entry",
|
||||
stderr,
|
||||
)
|
||||
|
||||
def test_required_key_with_properties_entry_accepted(self):
|
||||
schema = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
|
||||
"required": ["b"],
|
||||
}
|
||||
rc, stdout, stderr = self.run_schemagen(schema)
|
||||
self.assertEqual(rc, 0, msg=stderr)
|
||||
|
||||
|
||||
class SchemagenCyclicArrayTest(unittest.TestCase):
|
||||
"""Regression tests for issue #33: cyclic array $ref targets."""
|
||||
|
||||
@@ -310,6 +411,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 +428,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 +442,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 +543,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 +786,7 @@ class SchemagenIntegerBoundaryTest(unittest.TestCase):
|
||||
)
|
||||
schema = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {"age": {"type": "integer"}},
|
||||
"required": ["age"],
|
||||
}
|
||||
@@ -795,14 +902,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 +925,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 +938,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)
|
||||
|
||||
@@ -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):
|
||||
@@ -426,6 +432,22 @@ class Builder:
|
||||
)
|
||||
required = set(node.get("required", []))
|
||||
props = node.get("properties", {})
|
||||
# A `required` entry with no matching `properties` key makes the schema
|
||||
# unsatisfiable: `additionalProperties: false` (the only supported
|
||||
# object mode) rejects any key not listed in `properties`, so the
|
||||
# required key can never be present. No seen bit is emitted for such a
|
||||
# key, so the generated parser would silently accept every document.
|
||||
# Follow the other unsupported constructs and reject at generation time.
|
||||
dangling = sorted(required - set(props))
|
||||
if dangling:
|
||||
quoted = ", ".join(f"'{k}'" for k in dangling)
|
||||
if len(dangling) == 1:
|
||||
raise GenError(
|
||||
f"required key {quoted} has no matching properties entry"
|
||||
)
|
||||
raise GenError(
|
||||
f"required keys {quoted} have no matching properties entries"
|
||||
)
|
||||
seen_cpp = set()
|
||||
for key, sub in props.items():
|
||||
ty, nullable = self._unpack(self.build_type(sub, key))
|
||||
|
||||
Reference in New Issue
Block a user