diff --git a/octodns/record/base.py b/octodns/record/base.py index 501820b..a7c8e81 100644 --- a/octodns/record/base.py +++ b/octodns/record/base.py @@ -6,6 +6,8 @@ from collections import defaultdict from copy import deepcopy from logging import getLogger +from jsonschema import Draft202012Validator + from ..context import ContextDict from ..deprecation import deprecated from ..equality import EqualityTupleMixin @@ -24,6 +26,7 @@ class Record(EqualityTupleMixin): log = getLogger('Record') _CLASSES = {} + _SCHEMAS = {} @classmethod def register_type(cls, _class, _type=None): @@ -37,58 +40,18 @@ class Record(EqualityTupleMixin): raise RecordException(msg) cls._CLASSES[_type] = _class + try: + cls._SCHEMAS[_type] = Draft202012Validator( + schema=_class.jsonschema(), + format_checker=Draft202012Validator.FORMAT_CHECKER, + ) + except AttributeError: + pass + @classmethod def registered_types(cls): return cls._CLASSES - @classmethod - def jsonschema(cls): - schema = { - # base Record requirements - 'title': 'Record', - 'type': 'object', - 'properties': { - 'type': {'type': 'string', 'enum': list(cls._CLASSES.keys())} - }, - } - - class_schemas = [] - for _type, _class in cls._CLASSES.items(): - _value_type = _class._value_type - if not hasattr(_value_type, 'jsonschema'): - # type does not support schema - continue - class_schemas.append( - { - 'if': {'properties': {'type': {'enum': [_type]}}}, - 'then': { - 'title': _type, - 'properties': { - 'type': {}, - 'ttl': { - 'type': 'integer', - 'minimum': 0, - 'maximum': 86400, - }, - 'value': _class._value_type.jsonschema(), - }, - 'required': ['ttl', 'type', 'value'], - "unevaluatedProperties": False, - }, - } - ) - - if class_schemas: - schema['allOf'] = class_schemas - - # validate(schema=schema, instance={ - # 'type': 'A', - # 'ttl': 42, - # 'value': 'nope', - # }, format_checker=Draft202012Validator.FORMAT_CHECKER) - - return schema - @classmethod def new(cls, zone, name, data, source=None, lenient=False): reasons = [] @@ -118,7 +81,20 @@ class Record(EqualityTupleMixin): if context: msg += f', {context}' raise Exception(msg) - reasons.extend(_class.validate(name, fqdn, data)) + + validator = cls._SCHEMAS.get(_type) + if validator: + # new jsonschema based validaton + for e in validator.iter_errors(data): + message = e.message + for frm, to in e.schema.get('translations', {}).items(): + if frm in message: + message = to + reasons.append(message) + else: + # original .validate + reasons.extend(_class.validate(name, fqdn, data)) + try: lenient |= data['octodns']['lenient'] except KeyError: @@ -347,6 +323,32 @@ class Record(EqualityTupleMixin): class ValuesMixin(object): + + @classmethod + def jsonschema(cls): + value_type = cls._value_type + schema = ( + value_type.jsonschema() if hasattr(value_type, 'jsonschema') else {} + ) + + return { + 'title': cls._type, + 'properties': { + # TODO: what schema validations make sense for octodns? + 'octodns': {}, + 'type': {'const': cls._type}, + 'ttl': {'type': 'integer', 'minimum': 0, 'maximum': 86400}, + 'value': schema, + 'values': {'type': 'array', 'items': schema, 'minItems': 1}, + }, + 'required': ['ttl', 'type'], + 'oneOf': [{'required': ['value']}, {'required': ['values']}], + 'translations': { + 'is not valid under any of the given schemas': 'missing value(s)' + }, + "unevaluatedProperties": False, + } + @classmethod def validate(cls, name, fqdn, data): reasons = super().validate(name, fqdn, data) @@ -416,6 +418,25 @@ class ValuesMixin(object): class ValueMixin(object): + + @classmethod + def jsonschema(cls): + value_type = cls._value_type + schema = ( + value_type.jsonschema() if hasattr(value_type, 'jsonattr') else {} + ) + + return { + 'title': cls._type, + 'properties': { + 'type': {}, + 'ttl': {'type': 'integer', 'minimum': 0, 'maximum': 86400}, + 'value': schema, + }, + 'required': ['ttl', 'type', 'value'], + "unevaluatedProperties": False, + } + @classmethod def validate(cls, name, fqdn, data): reasons = super().validate(name, fqdn, data) diff --git a/tests/test_octodns_record_a.py b/tests/test_octodns_record_a.py index 861d321..934f92f 100644 --- a/tests/test_octodns_record_a.py +++ b/tests/test_octodns_record_a.py @@ -108,14 +108,14 @@ class TestRecordA(TestCase): Record.new( self.zone, 'www', {'type': 'A', 'ttl': 600, 'values': []} ) - self.assertEqual(['missing value(s)'], ctx.exception.reasons) + self.assertEqual(['[] should be non-empty'], ctx.exception.reasons) # missing value(s), None values with self.assertRaises(ValidationError) as ctx: Record.new( self.zone, 'www', {'type': 'A', 'ttl': 600, 'values': None} ) - self.assertEqual(['missing value(s)'], ctx.exception.reasons) + self.assertEqual(["None is not of type 'array'"], ctx.exception.reasons) # missing value(s) and empty value with self.assertRaises(ValidationError) as ctx: @@ -125,7 +125,8 @@ class TestRecordA(TestCase): {'type': 'A', 'ttl': 600, 'values': [None, '']}, ) self.assertEqual( - ['missing value(s)', 'empty value'], ctx.exception.reasons + ["None is not of type 'string'", "'' is not a 'ipv4'"], + ctx.exception.reasons, ) # missing value(s), None value @@ -133,18 +134,21 @@ class TestRecordA(TestCase): Record.new( self.zone, 'www', {'type': 'A', 'ttl': 600, 'value': None} ) - self.assertEqual(['missing value(s)'], ctx.exception.reasons) + self.assertEqual( + ["None is not of type 'string'"], ctx.exception.reasons + ) # empty value, empty string value with self.assertRaises(ValidationError) as ctx: Record.new(self.zone, 'www', {'type': 'A', 'ttl': 600, 'value': ''}) - self.assertEqual(['empty value'], ctx.exception.reasons) + self.assertEqual(["'' is not a 'ipv4'"], ctx.exception.reasons) # missing value(s) & ttl with self.assertRaises(ValidationError) as ctx: Record.new(self.zone, '', {'type': 'A'}) self.assertEqual( - ['missing ttl', 'missing value(s)'], ctx.exception.reasons + ["'ttl' is a required property", 'missing value(s)'], + ctx.exception.reasons, ) # invalid ipv4 address @@ -152,9 +156,7 @@ class TestRecordA(TestCase): Record.new( self.zone, '', {'type': 'A', 'ttl': 600, 'value': 'hello'} ) - self.assertEqual( - ['invalid IPv4 address "hello"'], ctx.exception.reasons - ) + self.assertEqual(["'hello' is not a 'ipv4'"], ctx.exception.reasons) # invalid ipv4 addresses with self.assertRaises(ValidationError) as ctx: @@ -164,7 +166,7 @@ class TestRecordA(TestCase): {'type': 'A', 'ttl': 600, 'values': ['hello', 'goodbye']}, ) self.assertEqual( - ['invalid IPv4 address "hello"', 'invalid IPv4 address "goodbye"'], + ["'hello' is not a 'ipv4'", "'goodbye' is not a 'ipv4'"], ctx.exception.reasons, ) @@ -176,6 +178,6 @@ class TestRecordA(TestCase): {'type': 'A', 'values': ['1.2.3.4', 'hello', '5.6.7.8']}, ) self.assertEqual( - ['missing ttl', 'invalid IPv4 address "hello"'], + ["'hello' is not a 'ipv4'", "'ttl' is a required property"], ctx.exception.reasons, ) diff --git a/tests/test_octodns_record_aaaa.py b/tests/test_octodns_record_aaaa.py index 6efe3a1..4925afb 100644 --- a/tests/test_octodns_record_aaaa.py +++ b/tests/test_octodns_record_aaaa.py @@ -87,14 +87,14 @@ class TestRecordAaaa(TestCase): Record.new( self.zone, 'www', {'type': 'AAAA', 'ttl': 600, 'values': []} ) - self.assertEqual(['missing value(s)'], ctx.exception.reasons) + self.assertEqual(['[] should be non-empty'], ctx.exception.reasons) # missing value(s), None values with self.assertRaises(ValidationError) as ctx: Record.new( self.zone, 'www', {'type': 'AAAA', 'ttl': 600, 'values': None} ) - self.assertEqual(['missing value(s)'], ctx.exception.reasons) + self.assertEqual(["None is not of type 'array'"], ctx.exception.reasons) # missing value(s) and empty value with self.assertRaises(ValidationError) as ctx: @@ -104,7 +104,8 @@ class TestRecordAaaa(TestCase): {'type': 'AAAA', 'ttl': 600, 'values': [None, '']}, ) self.assertEqual( - ['missing value(s)', 'empty value'], ctx.exception.reasons + ["None is not of type 'string'", "'' is not a 'ipv6'"], + ctx.exception.reasons, ) # missing value(s), None value @@ -112,20 +113,23 @@ class TestRecordAaaa(TestCase): Record.new( self.zone, 'www', {'type': 'AAAA', 'ttl': 600, 'value': None} ) - self.assertEqual(['missing value(s)'], ctx.exception.reasons) + self.assertEqual( + ["None is not of type 'string'"], ctx.exception.reasons + ) # empty value, empty string value with self.assertRaises(ValidationError) as ctx: Record.new( self.zone, 'www', {'type': 'AAAA', 'ttl': 600, 'value': ''} ) - self.assertEqual(['empty value'], ctx.exception.reasons) + self.assertEqual(["'' is not a 'ipv6'"], ctx.exception.reasons) # missing value(s) & ttl with self.assertRaises(ValidationError) as ctx: Record.new(self.zone, '', {'type': 'AAAA'}) self.assertEqual( - ['missing ttl', 'missing value(s)'], ctx.exception.reasons + ["'ttl' is a required property", 'missing value(s)'], + ctx.exception.reasons, ) # invalid IPv6 address @@ -133,9 +137,7 @@ class TestRecordAaaa(TestCase): Record.new( self.zone, '', {'type': 'AAAA', 'ttl': 600, 'value': 'hello'} ) - self.assertEqual( - ['invalid IPv6 address "hello"'], ctx.exception.reasons - ) + self.assertEqual(["'hello' is not a 'ipv6'"], ctx.exception.reasons) # invalid IPv6 addresses with self.assertRaises(ValidationError) as ctx: @@ -145,7 +147,7 @@ class TestRecordAaaa(TestCase): {'type': 'AAAA', 'ttl': 600, 'values': ['hello', 'goodbye']}, ) self.assertEqual( - ['invalid IPv6 address "hello"', 'invalid IPv6 address "goodbye"'], + ["'hello' is not a 'ipv6'", "'goodbye' is not a 'ipv6'"], ctx.exception.reasons, ) @@ -164,7 +166,7 @@ class TestRecordAaaa(TestCase): }, ) self.assertEqual( - ['missing ttl', 'invalid IPv6 address "hello"'], + ["'hello' is not a 'ipv6'", "'ttl' is a required property"], ctx.exception.reasons, ) @@ -197,9 +199,7 @@ class TestRecordAaaa(TestCase): Record.new( self.zone, '', {'type': 'AAAA', 'ttl': 600, 'value': 'hello'} ) - self.assertEqual( - ['invalid IPv6 address "hello"'], ctx.exception.reasons - ) + self.assertEqual(["'hello' is not a 'ipv6'"], ctx.exception.reasons) with self.assertRaises(ValidationError) as ctx: Record.new( self.zone, @@ -207,10 +207,7 @@ class TestRecordAaaa(TestCase): {'type': 'AAAA', 'ttl': 600, 'values': ['1.2.3.4', '2.3.4.5']}, ) self.assertEqual( - [ - 'invalid IPv6 address "1.2.3.4"', - 'invalid IPv6 address "2.3.4.5"', - ], + ["'1.2.3.4' is not a 'ipv6'", "'2.3.4.5' is not a 'ipv6'"], ctx.exception.reasons, ) @@ -222,6 +219,6 @@ class TestRecordAaaa(TestCase): {'type': 'AAAA', 'ttl': 600, 'values': ['hello', 'goodbye']}, ) self.assertEqual( - ['invalid IPv6 address "hello"', 'invalid IPv6 address "goodbye"'], + ["'hello' is not a 'ipv6'", "'goodbye' is not a 'ipv6'"], ctx.exception.reasons, )