From 220dfb8164fb410317e266139f57de36340d9c5f Mon Sep 17 00:00:00 2001 From: Ryan Barrett Date: Thu, 26 Sep 2024 13:19:28 -0700 Subject: [PATCH] schema validation: handle refs, unions, etc inside arrays for #3 --- lexrpc/base.py | 48 +++++++++++++++-------- lexrpc/tests/lexicons.py | 36 ++++++++--------- lexrpc/tests/test_base.py | 82 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 127 insertions(+), 39 deletions(-) diff --git a/lexrpc/base.py b/lexrpc/base.py index 4ad312b..75410f8 100644 --- a/lexrpc/base.py +++ b/lexrpc/base.py @@ -294,6 +294,16 @@ def _validate_schema(self, val, type_, name=None, schema=None): Raises: ValidationError: if the value is invalid """ + def get_schema(name): + """Returns (fully qualified lexicon name, lexicon) tuple.""" + schema_name = urljoin(type_, name) + schema = self._get_def(schema_name) + if schema.get('type') == 'record': + schema = schema.get('record') + if not schema: + fail(f'lexicon {schema_name} not found') + return schema_name, schema + def fail(msg): val_str = repr(val) if len(val_str) > 50: @@ -353,22 +363,29 @@ def fail(msg): elif val not in self.defs: fail(f'not found') - if type_ in ('ref', 'union'): - if not isinstance(val, (str, dict)): - fail("is invalid") - - if type_ == 'ref': - inner_type = schema['ref'] - else: - inner_type = val.get('$type') if isinstance(val, dict) else val + if type_ == 'ref': + ref = schema['ref'] + if isinstance(val, str) and val != ref: + fail(f'is not {ref}') + elif not isinstance(val, dict): + fail('is not object') + type_, schema = get_schema(ref) + + if type_ == 'union': + if isinstance(val, dict): + inner_type = val.get('$type') if not inner_type: fail('missing $type') - refs = schema['refs'] - if inner_type not in refs: - fail(f"isn't one of {refs}") + elif isinstance(val, str): + inner_type = val + else: + fail("is invalid") + + refs = schema['refs'] + if inner_type not in refs: + fail(f"{inner_type} isn't one of {refs}") - # if it's a fragment, fully qualify it - schema = self._get_def(urljoin(type_, inner_type)) + type_, schema = get_schema(inner_type) if type_ == 'blob': max_size = schema.get('maxSize') @@ -406,10 +423,7 @@ def fail(msg): continue if inner_type == 'ref': - # if it's a fragment, fully qualify it - inner_type = urljoin(type_, inner_schema['ref']) - inner_schema = self._get_def(inner_type) - # TODO: union + inner_type, inner_schema = get_schema(inner_schema['ref']) self._validate_schema(inner_val, inner_type, name=inner_name, schema=inner_schema) diff --git a/lexrpc/tests/lexicons.py b/lexrpc/tests/lexicons.py index a5fa122..b7cf76b 100644 --- a/lexrpc/tests/lexicons.py +++ b/lexrpc/tests/lexicons.py @@ -199,7 +199,7 @@ 'type': 'array', 'items': { 'type': 'ref', - 'ref': '#object' + 'ref': 'io.example.record' }, }, }, @@ -222,8 +222,8 @@ 'items': { 'type': 'union', 'refs': [ - 'com.example.kitchenSink#object', - 'com.example.kitchenSink#subobject', + 'io.example.kitchenSink#object', + 'io.example.kitchenSink#subobject', ], }, }, @@ -380,7 +380,7 @@ { 'lexicon': 1, - 'id': 'com.example.stringLength', + 'id': 'io.example.stringLength', 'defs': { 'main': { 'type': 'record', @@ -459,7 +459,7 @@ # { # 'lexicon': 1, - # 'id': 'com.example.union', + # 'id': 'io.example.union', # 'defs': { # 'main': { # 'type': 'record', @@ -472,16 +472,16 @@ # 'unionOpen': { # 'type': 'union', # 'refs': [ - # 'com.example.kitchenSink#object', - # 'com.example.kitchenSink#subobject', + # 'io.example.kitchenSink#object', + # 'io.example.kitchenSink#subobject', # ], # }, # 'unionClosed': { # 'type': 'union', # 'closed': True, # 'refs': [ - # 'com.example.kitchenSink#object', - # 'com.example.kitchenSink#subobject', + # 'io.example.kitchenSink#object', + # 'io.example.kitchenSink#subobject', # ], # }, # }, @@ -492,7 +492,7 @@ # { # 'lexicon': 1, - # 'id': 'com.example.unknown', + # 'id': 'io.example.unknown', # 'defs': { # 'main': { # 'type': 'record', @@ -512,7 +512,7 @@ # { # 'lexicon': 1, - # 'id': 'com.example.arrayLength', + # 'id': 'io.example.arrayLength', # 'defs': { # 'main': { # 'type': 'record', @@ -533,7 +533,7 @@ # { # 'lexicon': 1, - # 'id': 'com.example.boolConst', + # 'id': 'io.example.boolConst', # 'defs': { # 'main': { # 'type': 'record', @@ -552,7 +552,7 @@ # { # 'lexicon': 1, - # 'id': 'com.example.integerRange', + # 'id': 'io.example.integerRange', # 'defs': { # 'main': { # 'type': 'record', @@ -572,7 +572,7 @@ # { # 'lexicon': 1, - # 'id': 'com.example.integerEnum', + # 'id': 'io.example.integerEnum', # 'defs': { # 'main': { # 'type': 'record', @@ -591,7 +591,7 @@ # { # 'lexicon': 1, - # 'id': 'com.example.integerConst', + # 'id': 'io.example.integerConst', # 'defs': { # 'main': { # 'type': 'record', @@ -610,7 +610,7 @@ # { # 'lexicon': 1, - # 'id': 'com.example.integerRange', + # 'id': 'io.example.integerRange', # 'defs': { # 'main': { # 'type': 'record', @@ -630,7 +630,7 @@ # { # 'lexicon': 1, - # 'id': 'com.example.integerEnum', + # 'id': 'io.example.integerEnum', # 'defs': { # 'main': { # 'type': 'record', @@ -649,7 +649,7 @@ # { # 'lexicon': 1, - # 'id': 'com.example.integerConst', + # 'id': 'io.example.integerConst', # 'defs': { # 'main': { # 'type': 'record', diff --git a/lexrpc/tests/test_base.py b/lexrpc/tests/test_base.py index 812bae4..f8d4d52 100644 --- a/lexrpc/tests/test_base.py +++ b/lexrpc/tests/test_base.py @@ -48,8 +48,8 @@ def test_validate_truncate(self): with self.subTest(input=input, expected=expected): self.assertEqual( {'string': expected}, - base.validate('com.example.stringLength', 'record', - {'string': input})) + base.validate('io.example.stringLength', 'record', + {'string': input})) def test_validate_record_pass_nested_optional_field_missing(self): self.base.validate('io.example.record', 'record', { @@ -118,7 +118,7 @@ def test_validate_record_object_array_fail_bad_type(self): with self.assertRaises(ValidationError): self.base.validate('io.example.objectArray', 'record', { 'foo': [ - {'bar': 'x'} + {'bar': 'x'}, ], }) @@ -126,6 +126,80 @@ def test_validate_record_object_array_fail_missing_required(self): with self.assertRaises(ValidationError): self.base.validate('io.example.objectArray', 'record', { 'foo': [ - {'baz': 'x'} + {'baz': 'x'}, ], }) + + def test_validate_record_ref_array_pass(self): + self.base.validate('io.example.refArray', 'record', {'foo': []}) + + self.base.validate('io.example.refArray', 'record', { + 'foo': [ + {'baz': 5}, + {'baz': 5, 'biff': {'baj': 'ok'}}, + ], + }) + + def test_validate_record_ref_array_fail_bad_type(self): + with self.assertRaises(ValidationError): + self.base.validate('io.example.refArray', 'record', { + 'foo': [{'baz': 'x'}], + }) + + with self.assertRaises(ValidationError): + self.base.validate('io.example.refArray', 'record', { + 'foo': [{ + 'baz': 'x', + 'biff': {'baj': 5}, + }], + }) + + def test_validate_record_ref_array_fail_item_not_nullable(self): + with self.assertRaises(ValidationError): + self.base.validate('io.example.refArray', 'record', { + 'foo': [ + {'baz': None}, + ], + }) + + def test_validate_record_union_array_pass(self): + self.base.validate('io.example.unionArray', 'record', {'foo': []}) + + self.base.validate('io.example.unionArray', 'record', { + 'foo': [{ + '$type': 'io.example.kitchenSink#object', + 'subobject': {'boolean': True}, + 'array': [], + 'boolean': False, + 'integer': 0, + 'string': 'ok', + }, { + '$type': 'io.example.kitchenSink#subobject', + 'boolean': False, + }], + }) + + def test_validate_record_union_array_fail_bad_type(self): + for bad in [ + 123, + {'$type': 'io.example.kitchenSink#subobject', 'boolean': 123}, + {'$type': 'io.example.record', 'baz': 123}, + ]: + with self.subTest(bad=bad), self.assertRaises(ValidationError): + self.base.validate('io.example.unionArray', 'record', { + 'foo': [123], + }) + + def test_validate_record_union_array_fail_inner_array(self): + for bad in 123, [123], ['x', 123], [{'y': 'z'}]: + with self.subTest(bad=bad), self.assertRaises(ValidationError): + self.base.validate('io.example.unionArray', 'record', { + 'foo': [{ + '$type': 'io.example.kitchenSink#object', + 'subobject': {'boolean': True}, + 'array': bad, + 'boolean': False, + 'integer': 0, + 'string': 'ok', + }], + })