Support custom properties in schemas for v2 API

* Add glance schema API (glance.schema:API)
* Disallow custom properties that conflict with base properties
* Implements bp api-v2-schemas

Change-Id: Ibfa617cb5edf16627627debc30149669213d4b2d
This commit is contained in:
Brian Waldon 2012-05-04 22:26:52 -07:00
parent 7553b4fcb6
commit eee5fecdf6
13 changed files with 333 additions and 58 deletions

1
etc/schema-access.json Normal file
View File

@ -0,0 +1 @@
{}

14
etc/schema-image.json Normal file
View File

@ -0,0 +1,14 @@
{
"type": {
"type": "string",
"description": "The type code for the image"
"required": true,
"enum": ["kernel", "ramdisk", "filesystem", "iso9660", "disk"]
},
"format": {
"type": "string",
"description": "The format code for the image"
"required": true,
"enum": ["raw", "vhd", "vmdk", "vdi", "qcow2", "qed"]
},
}

View File

@ -19,7 +19,6 @@ import jsonschema
import webob.exc
from glance.api.v2 import base
from glance.api.v2 import schemas
from glance.common import exception
from glance.common import wsgi
import glance.registry.db.api
@ -77,12 +76,13 @@ class Controller(base.Controller):
class RequestDeserializer(wsgi.JSONRequestDeserializer):
def __init__(self, conf):
def __init__(self, conf, schema_api):
super(RequestDeserializer, self).__init__()
self.conf = conf
self.schema_api = schema_api
def _validate(self, request, obj):
schema = schemas.SchemasController(self.conf).access(request)
schema = self.schema_api.get_schema('access')
jsonschema.validate(obj, schema)
def create(self, request):
@ -140,9 +140,9 @@ class ResponseSerializer(wsgi.JSONResponseSerializer):
response.status_int = 204
def create_resource(conf):
def create_resource(conf, schema_api):
"""Image access resource factory method"""
deserializer = RequestDeserializer(conf)
deserializer = RequestDeserializer(conf, schema_api)
serializer = ResponseSerializer()
controller = Controller(conf)
return wsgi.Resource(controller, deserializer, serializer)

View File

@ -66,12 +66,13 @@ class ImagesController(base.Controller):
class RequestDeserializer(wsgi.JSONRequestDeserializer):
def __init__(self, conf):
def __init__(self, conf, schema_api):
super(RequestDeserializer, self).__init__()
self.conf = conf
self.schema_api = schema_api
def _validate(self, request, obj):
schema = schemas.SchemasController(self.conf).image(request)
schema = self.schema_api.get_schema('image')
jsonschema.validate(obj, schema)
def create(self, request):
@ -131,9 +132,9 @@ class ResponseSerializer(wsgi.JSONResponseSerializer):
response.status_int = 204
def create_resource(conf):
def create_resource(conf, schema_api):
"""Images resource factory method"""
deserializer = RequestDeserializer(conf)
deserializer = RequestDeserializer(conf, schema_api)
serializer = ResponseSerializer()
controller = ImagesController(conf)
return wsgi.Resource(controller, deserializer, serializer)

View File

@ -26,6 +26,7 @@ from glance.api.v2 import images
from glance.api.v2 import root
from glance.api.v2 import schemas
from glance.common import wsgi
import glance.schema
logger = logging.getLogger(__name__)
@ -38,10 +39,12 @@ class API(wsgi.Router):
self.conf = conf
mapper = routes.Mapper()
schema_api = glance.schema.API()
root_resource = root.create_resource(conf)
mapper.connect('/', controller=root_resource, action='index')
schemas_resource = schemas.create_resource(conf)
schemas_resource = schemas.create_resource(conf, schema_api)
mapper.connect('/schemas',
controller=schemas_resource,
action='index',
@ -55,7 +58,7 @@ class API(wsgi.Router):
action='access',
conditions={'method': ['GET']})
images_resource = images.create_resource(conf)
images_resource = images.create_resource(conf, schema_api)
mapper.connect('/images',
controller=images_resource,
action='index',
@ -101,7 +104,7 @@ class API(wsgi.Router):
action='delete',
conditions={'method': ['DELETE']})
image_access_resource = image_access.create_resource(conf)
image_access_resource = image_access.create_resource(conf, schema_api)
mapper.connect('/images/{image_id}/access',
controller=image_access_resource,
action='index',

View File

@ -15,45 +15,14 @@
import glance.api.v2.base
from glance.common import wsgi
import glance.schema
#NOTE(bcwaldon): this is temporary until we generate them on the fly
IMAGE_SCHEMA = {
"name": "image",
"properties": {
"id": {
"type": "string",
"description": "An identifier for the image",
"required": False,
"maxLength": 36,
},
"name": {
"type": "string",
"description": "Descriptive name for the image",
"required": True,
},
},
}
class Controller(glance.api.v2.base.Controller):
def __init__(self, conf, schema_api):
super(Controller, self).__init__(conf)
self.schema_api = schema_api
ACCESS_SCHEMA = {
'name': 'access',
'properties': {
"tenant_id": {
"type": "string",
"description": "The tenant identifier",
"required": True,
},
"can_share": {
"type": "boolean",
"description": "Ability of tenant to share with others",
"required": True,
"default": False,
},
},
}
class SchemasController(glance.api.v2.base.Controller):
def index(self, req):
links = [
{'rel': 'image', 'href': '/v2/schemas/image'},
@ -62,12 +31,12 @@ class SchemasController(glance.api.v2.base.Controller):
return {'links': links}
def image(self, req):
return IMAGE_SCHEMA
return self.schema_api.get_schema('image')
def access(self, req):
return ACCESS_SCHEMA
return self.schema_api.get_schema('access')
def create_resource(conf):
controller = SchemasController(conf)
def create_resource(conf, schema_api):
controller = Controller(conf, schema_api)
return wsgi.Resource(controller)

View File

@ -236,3 +236,7 @@ class RegionAmbiguity(GlanceException):
class WorkerCreationFailure(GlanceException):
message = _("Server worker creation failed: %(reason)s.")
class SchemaLoadError(GlanceException):
message = _("Unable to load schema: %(reason)s")

80
glance/schema.py Normal file
View File

@ -0,0 +1,80 @@
# Copyright 2012 OpenStack LLC.
# All Rights Reserved.
#
# 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.
import copy
from glance.common import exception
_BASE_SCHEMA_PROPERTIES = {
'image': {
'id': {
'type': 'string',
'description': 'An identifier for the image',
'required': False,
'maxLength': 36,
},
'name': {
'type': 'string',
'description': 'Descriptive name for the image',
'required': True,
'maxLength': 255,
},
},
'access': {
'tenant_id': {
'type': 'string',
'description': 'The tenant identifier',
'required': True,
},
'can_share': {
'type': 'boolean',
'description': 'Ability of tenant to share with others',
'required': True,
'default': False,
},
},
}
class API(object):
def __init__(self, base_properties=_BASE_SCHEMA_PROPERTIES):
self.base_properties = base_properties
self.schema_properties = copy.deepcopy(self.base_properties)
def get_schema(self, name):
return {
'name': name,
'properties': self.schema_properties[name],
}
def set_custom_schema_properties(self, schema_name, custom_properties):
"""Update the custom properties of a schema with those provided."""
schema_properties = copy.deepcopy(self.base_properties[schema_name])
# Ensure custom props aren't attempting to override base props
base_keys = set(schema_properties.keys())
custom_keys = set(custom_properties.keys())
intersecting_keys = base_keys.intersection(custom_keys)
conflicting_keys = [k for k in intersecting_keys
if schema_properties[k] != custom_properties[k]]
if len(conflicting_keys) > 0:
props = ', '.join(conflicting_keys)
reason = _("custom properties (%(props)s) conflict "
"with base properties")
raise exception.SchemaLoadError(reason=reason % {'props': props})
schema_properties.update(copy.deepcopy(custom_properties))
self.schema_properties[schema_name] = schema_properties

View File

@ -0,0 +1,35 @@
# Copyright 2012 OpenStack LLC.
# All Rights Reserved.
#
# 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.
import unittest
import glance.schema
class TestSchemaAPI(unittest.TestCase):
def test_load_image_schema(self):
schema_api = glance.schema.API()
output = schema_api.get_schema('image')
self.assertEqual('image', output['name'])
expected_keys = ['id', 'name']
self.assertEqual(expected_keys, output['properties'].keys())
def test_load_access_schema(self):
schema_api = glance.schema.API()
output = schema_api.get_schema('access')
self.assertEqual('access', output['name'])
expected_keys = ['tenant_id', 'can_share']
self.assertEqual(expected_keys, output['properties'].keys())

View File

@ -0,0 +1,162 @@
# Copyright 2012 OpenStack LLC.
# All Rights Reserved.
#
# 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.
import unittest
from glance.common import exception
import glance.schema
FAKE_BASE_PROPERTIES = {
'fake1': {
'id': {
'type': 'string',
'description': 'An identifier for the image',
'required': False,
'maxLength': 36,
},
'name': {
'type': 'string',
'description': 'Descriptive name for the image',
'required': True,
},
},
}
class TestSchemaAPI(unittest.TestCase):
def setUp(self):
self.schema_api = glance.schema.API(FAKE_BASE_PROPERTIES)
def test_get_schema(self):
output = self.schema_api.get_schema('fake1')
expected = {
'name': 'fake1',
'properties': {
'id': {
'type': 'string',
'description': 'An identifier for the image',
'required': False,
'maxLength': 36,
},
'name': {
'type': 'string',
'description': 'Descriptive name for the image',
'required': True,
},
},
}
self.assertEqual(output, expected)
def test_get_schema_after_load(self):
extra_props = {
'prop1': {
'type': 'string',
'description': 'Just some property',
'required': False,
'maxLength': 128,
},
}
self.schema_api.set_custom_schema_properties('fake1', extra_props)
output = self.schema_api.get_schema('fake1')
expected = {
'name': 'fake1',
'properties': {
'id': {
'type': 'string',
'description': 'An identifier for the image',
'required': False,
'maxLength': 36,
},
'name': {
'type': 'string',
'description': 'Descriptive name for the image',
'required': True,
},
'prop1': {
'type': 'string',
'description': 'Just some property',
'required': False,
'maxLength': 128,
},
},
}
self.assertEqual(output, expected)
def test_get_schema_load_conflict(self):
extra_props = {
'name': {
'type': 'int',
'description': 'Descriptive integer for the image',
'required': False,
},
}
self.assertRaises(exception.SchemaLoadError,
self.schema_api.set_custom_schema_properties,
'fake1',
extra_props)
# Schema should not have changed due to the conflict
output = self.schema_api.get_schema('fake1')
expected = {
'name': 'fake1',
'properties': {
'id': {
'type': 'string',
'description': 'An identifier for the image',
'required': False,
'maxLength': 36,
},
'name': {
'type': 'string',
'description': 'Descriptive name for the image',
'required': True,
},
},
}
self.assertEqual(output, expected)
def test_get_schema_load_conflict_base_property(self):
extra_props = {
'name': {
'type': 'string',
'description': 'Descriptive name for the image',
'required': True,
},
}
# Schema update should not raise an exception, but it should also
# remain unchanged
self.schema_api.set_custom_schema_properties('fake1', extra_props)
output = self.schema_api.get_schema('fake1')
expected = {
'name': 'fake1',
'properties': {
'id': {
'type': 'string',
'description': 'An identifier for the image',
'required': False,
'maxLength': 36,
},
'name': {
'type': 'string',
'description': 'Descriptive name for the image',
'required': True,
},
},
}
self.assertEqual(output, expected)

View File

@ -21,6 +21,7 @@ import webob
from glance.api.v2 import image_access
from glance.common import exception
from glance.common import utils
import glance.schema
import glance.tests.unit.utils as test_utils
@ -108,7 +109,8 @@ class TestImageAccessController(unittest.TestCase):
class TestImageAccessDeserializer(unittest.TestCase):
def setUp(self):
self.deserializer = image_access.RequestDeserializer({})
schema_api = glance.schema.API()
self.deserializer = image_access.RequestDeserializer({}, schema_api)
def test_create(self):
fixture = {

View File

@ -93,7 +93,9 @@ class TestImagesController(unittest.TestCase):
class TestImagesDeserializer(unittest.TestCase):
def setUp(self):
self.deserializer = glance.api.v2.images.RequestDeserializer({})
schema_api = glance.schema.API()
self.deserializer = glance.api.v2.images.RequestDeserializer(
{}, schema_api)
def test_create(self):
request = test_utils.FakeRequest()

View File

@ -15,15 +15,17 @@
import unittest
import glance.api.v2.schemas
from glance.api.v2 import schemas
import glance.tests.unit.utils as test_utils
import glance.schema
class TestSchemasController(unittest.TestCase):
def setUp(self):
super(TestSchemasController, self).setUp()
self.controller = glance.api.v2.schemas.SchemasController({})
self.schema_api = glance.schema.API()
self.controller = schemas.Controller({}, self.schema_api)
def test_index(self):
req = test_utils.FakeRequest()
@ -37,9 +39,9 @@ class TestSchemasController(unittest.TestCase):
def test_image(self):
req = test_utils.FakeRequest()
output = self.controller.image(req)
self.assertEqual(glance.api.v2.schemas.IMAGE_SCHEMA, output)
self.assertEqual(self.schema_api.get_schema('image'), output)
def test_access(self):
req = test_utils.FakeRequest()
output = self.controller.access(req)
self.assertEqual(glance.api.v2.schemas.ACCESS_SCHEMA, output)
self.assertEqual(self.schema_api.get_schema('access'), output)