Skip to content

Commit

Permalink
schema validation: handle refs, unions, etc inside arrays
Browse files Browse the repository at this point in the history
for #3
  • Loading branch information
snarfed committed Sep 26, 2024
1 parent a19cfe4 commit 220dfb8
Show file tree
Hide file tree
Showing 3 changed files with 127 additions and 39 deletions.
48 changes: 31 additions & 17 deletions lexrpc/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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)
Expand Down
36 changes: 18 additions & 18 deletions lexrpc/tests/lexicons.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@
'type': 'array',
'items': {
'type': 'ref',
'ref': '#object'
'ref': 'io.example.record'
},
},
},
Expand All @@ -222,8 +222,8 @@
'items': {
'type': 'union',
'refs': [
'com.example.kitchenSink#object',
'com.example.kitchenSink#subobject',
'io.example.kitchenSink#object',
'io.example.kitchenSink#subobject',
],
},
},
Expand Down Expand Up @@ -380,7 +380,7 @@

{
'lexicon': 1,
'id': 'com.example.stringLength',
'id': 'io.example.stringLength',
'defs': {
'main': {
'type': 'record',
Expand Down Expand Up @@ -459,7 +459,7 @@

# {
# 'lexicon': 1,
# 'id': 'com.example.union',
# 'id': 'io.example.union',
# 'defs': {
# 'main': {
# 'type': 'record',
Expand All @@ -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',
# ],
# },
# },
Expand All @@ -492,7 +492,7 @@

# {
# 'lexicon': 1,
# 'id': 'com.example.unknown',
# 'id': 'io.example.unknown',
# 'defs': {
# 'main': {
# 'type': 'record',
Expand All @@ -512,7 +512,7 @@

# {
# 'lexicon': 1,
# 'id': 'com.example.arrayLength',
# 'id': 'io.example.arrayLength',
# 'defs': {
# 'main': {
# 'type': 'record',
Expand All @@ -533,7 +533,7 @@

# {
# 'lexicon': 1,
# 'id': 'com.example.boolConst',
# 'id': 'io.example.boolConst',
# 'defs': {
# 'main': {
# 'type': 'record',
Expand All @@ -552,7 +552,7 @@

# {
# 'lexicon': 1,
# 'id': 'com.example.integerRange',
# 'id': 'io.example.integerRange',
# 'defs': {
# 'main': {
# 'type': 'record',
Expand All @@ -572,7 +572,7 @@

# {
# 'lexicon': 1,
# 'id': 'com.example.integerEnum',
# 'id': 'io.example.integerEnum',
# 'defs': {
# 'main': {
# 'type': 'record',
Expand All @@ -591,7 +591,7 @@

# {
# 'lexicon': 1,
# 'id': 'com.example.integerConst',
# 'id': 'io.example.integerConst',
# 'defs': {
# 'main': {
# 'type': 'record',
Expand All @@ -610,7 +610,7 @@

# {
# 'lexicon': 1,
# 'id': 'com.example.integerRange',
# 'id': 'io.example.integerRange',
# 'defs': {
# 'main': {
# 'type': 'record',
Expand All @@ -630,7 +630,7 @@

# {
# 'lexicon': 1,
# 'id': 'com.example.integerEnum',
# 'id': 'io.example.integerEnum',
# 'defs': {
# 'main': {
# 'type': 'record',
Expand All @@ -649,7 +649,7 @@

# {
# 'lexicon': 1,
# 'id': 'com.example.integerConst',
# 'id': 'io.example.integerConst',
# 'defs': {
# 'main': {
# 'type': 'record',
Expand Down
82 changes: 78 additions & 4 deletions lexrpc/tests/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down Expand Up @@ -118,14 +118,88 @@ 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'},
],
})

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',
}],
})

0 comments on commit 220dfb8

Please sign in to comment.