Add basic validation functionality to DesignateObjects

In this commit, we add support for basic (i.e. non-nested) validation of
DesignateObjects. Future commits will enhance this to support nested
validation, as well as providing schemas for each of the existing Objects

Change-Id: Ib41e51d70c942332ff872f348d0d5d53fb32d10f
Blueprint: validation-cleanup
This commit is contained in:
Kiall Mac Innes 2014-10-20 12:46:56 +01:00
parent 282d22f350
commit 4964a91f11
5 changed files with 228 additions and 3 deletions

View File

@ -44,3 +44,5 @@ from designate.objects.server import Server, ServerList # noqa
from designate.objects.tenant import Tenant, TenantList # noqa
from designate.objects.tld import Tld, TldList # noqa
from designate.objects.tsigkey import TsigKey, TsigKeyList # noqa
from designate.objects.validation_error import ValidationError # noqa
from designate.objects.validation_error import ValidationErrorList # noqa

View File

@ -16,7 +16,13 @@ import copy
import six
from designate.openstack.common import log as logging
from designate import exceptions
from designate.schema import validators
from designate.schema import format
LOG = logging.getLogger(__name__)
class NotSpecifiedSentinel:
@ -64,6 +70,27 @@ def make_class_properties(cls):
setattr(cls, field, property(getter, setter))
def make_class_validator(cls):
schema = {
'$schema': 'http://json-schema.org/draft-04/hyper-schema',
'title': cls.obj_name(),
'description': 'Designate %s Object' % cls.obj_name(),
'type': 'object',
'additionalProperties': False,
'required': [],
'properties': {},
}
for name, properties in cls.FIELDS.items():
schema['properties'][name] = properties.get('schema', {})
if properties.get('required', False):
schema['required'].append(name)
cls._obj_validator = validators.Draft4Validator(
schema, format_checker=format.draft4_format_checker)
class DesignateObjectMetaclass(type):
def __init__(cls, names, bases, dict_):
if not hasattr(cls, '_obj_classes'):
@ -73,6 +100,7 @@ class DesignateObjectMetaclass(type):
return
make_class_properties(cls)
make_class_validator(cls)
# Add a reference to the finished class into the _obj_classes
# dictionary, allowing us to lookup classes by their name later - this
@ -132,6 +160,11 @@ class DesignateObject(object):
"""
return cls.__name__
@classmethod
def obj_get_schema(cls):
"""Returns the JSON Schema for this Object."""
return cls._obj_validator.schema
def __init__(self, **kwargs):
self._obj_changes = set()
self._obj_original_values = dict()
@ -140,7 +173,8 @@ class DesignateObject(object):
if name in self.FIELDS.keys():
setattr(self, name, value)
else:
raise TypeError("'%s' is an invalid keyword argument" % name)
raise TypeError("__init__() got an unexpected keyword "
"argument '%(name)s'" % {'name': name})
def to_primitive(self):
"""
@ -184,6 +218,32 @@ class DesignateObject(object):
for k, v in values.iteritems():
setattr(self, k, v)
@property
def is_valid(self):
"""Returns True if the Object is valid."""
return self._obj_validator.is_valid(self.to_dict())
def validate(self):
# NOTE(kiall): We make use of the Object registry here in order to
# avoid an impossible circular import.
ValidationErrorList = self.obj_cls_from_name('ValidationErrorList')
ValidationError = self.obj_cls_from_name('ValidationError')
values = self.to_dict()
errors = ValidationErrorList()
LOG.debug("Validating '%(name)s' object with values: %(values)r", {
'name': self.obj_name(),
'values': values,
})
for error in self._obj_validator.iter_errors(values):
errors.append(ValidationError.from_js_error(error))
if len(errors) > 0:
raise exceptions.InvalidObject("Provided object does not match "
"schema", errors=errors)
def obj_attr_is_set(self, name):
"""
Return True or False depending of if a particular attribute has had
@ -425,7 +485,31 @@ class PersistentObjectMixin(object):
This adds the fields that we use in common for all persistent objects.
"""
FIELDS = {'id': {}, 'created_at': {}, 'updated_at': {}, 'version': {}}
FIELDS = {
'id': {
'schema': {
'type': 'string',
'format': 'uuid',
}
},
'created_at': {
'schema': {
'type': 'string',
'format': 'date-time',
}
},
'updated_at': {
'schema': {
'type': ['string', 'null'],
'format': 'date-time',
}
},
'version': {
'schema': {
'type': 'integer',
}
}
}
class SoftDeleteObjectMixin(object):
@ -434,4 +518,16 @@ class SoftDeleteObjectMixin(object):
This adds the fields that we use in common for all soft-deleted objects.
"""
FIELDS = {'deleted': {}, 'deleted_at': {}}
FIELDS = {
'deleted': {
'schema': {
'type': ['string', 'integer'],
}
},
'deleted_at': {
'schema': {
'type': ['string', 'null'],
'format': 'date-time',
}
}
}

View File

@ -0,0 +1,43 @@
# Copyright 2014 Hewlett-Packard Development Company, L.P.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from designate.objects import base
class ValidationError(base.DesignateObject):
FIELDS = {
'relative_path': {},
'absolute_path': {},
'message': {},
'validator': {},
'validator_value': {},
}
@classmethod
def from_js_error(cls, js_error):
"""Convert a JSON Schema ValidationError instance into a
ValidationError instance.
"""
e = cls()
e.relative_path = ".".join([str(x) for x in js_error.relative_path])
e.absolute_path = ".".join([str(x) for x in js_error.absolute_path])
e.message = js_error.message
e.validator = js_error.validator
e.validator_value = js_error.validator_value
return e
class ValidationErrorList(base.ListObjectMixin, base.DesignateObject):
LIST_ITEM_TYPE = ValidationError

View File

@ -146,6 +146,9 @@ class SQLAlchemy(object):
return query
def _create(self, table, obj, exc_dup, skip_values=None):
# Ensure the Object is valid
obj.validate()
values = obj.obj_get_changes()
if skip_values is not None:
@ -234,6 +237,9 @@ class SQLAlchemy(object):
def _update(self, context, table, obj, exc_dup, exc_notfound,
skip_values=None):
# Ensure the Object is valid
obj.validate()
values = obj.obj_get_changes()
if skip_values is not None:

View File

@ -20,6 +20,7 @@ import testtools
from designate.openstack.common import log as logging
from designate import tests
from designate import objects
from designate import exceptions
LOG = logging.getLogger(__name__)
@ -41,6 +42,18 @@ class TestObjectList(objects.ListObjectMixin, objects.DesignateObject):
pass
class TestValidatableObject(objects.DesignateObject):
FIELDS = {
'id': {
'schema': {
'type': 'string',
'format': 'uuid',
},
'required': True,
},
}
class DesignateObjectTest(tests.TestCase):
def test_obj_cls_from_name(self):
cls = objects.DesignateObject.obj_cls_from_name('TestObject')
@ -184,6 +197,71 @@ class DesignateObjectTest(tests.TestCase):
}
self.assertEqual(expected, primitive)
def test_to_dict(self):
obj = TestObject(id='MyID')
# Ensure only the id attribute is returned
dict_ = obj.to_dict()
expected = {
'id': 'MyID',
}
self.assertEqual(expected, dict_)
# Set the name attribute to a None value
obj.name = None
# Ensure both the id and name attributes are returned
dict_ = obj.to_dict()
expected = {
'id': 'MyID',
'name': None,
}
self.assertEqual(expected, dict_)
def test_to_dict_recursive(self):
obj = TestObject(id='MyID', nested=TestObject(id='MyID-Nested'))
# Ensure only the id attribute is returned
dict_ = obj.to_dict()
expected = {
'id': 'MyID',
'nested': {
'id': 'MyID-Nested',
},
}
self.assertEqual(expected, dict_)
def test_is_valid(self):
obj = TestValidatableObject(id='MyID')
# ID should be a UUID, So - Not Valid.
self.assertFalse(obj.is_valid)
# Correct the ID field
obj.id = 'ffded5c4-e4f6-4e02-a175-48e13c5c12a0'
# ID is now a UUID, So - Valid.
self.assertTrue(obj.is_valid)
def test_validate(self):
obj = TestValidatableObject()
# ID is required, so the object is not valid
with testtools.ExpectedException(exceptions.InvalidObject):
obj.validate()
# Set the ID field to an invalid value
obj.id = 'MyID'
# ID is now set, but to an invalid value, still invalid
with testtools.ExpectedException(exceptions.InvalidObject):
obj.validate()
# Set the ID field to a valid value
obj.id = 'ffded5c4-e4f6-4e02-a175-48e13c5c12a0'
obj.validate()
def test_obj_attr_is_set(self):
obj = TestObject()