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

View File

@ -66,12 +66,13 @@ class ImagesController(base.Controller):
class RequestDeserializer(wsgi.JSONRequestDeserializer): class RequestDeserializer(wsgi.JSONRequestDeserializer):
def __init__(self, conf): def __init__(self, conf, schema_api):
super(RequestDeserializer, self).__init__() super(RequestDeserializer, self).__init__()
self.conf = conf self.conf = conf
self.schema_api = schema_api
def _validate(self, request, obj): def _validate(self, request, obj):
schema = schemas.SchemasController(self.conf).image(request) schema = self.schema_api.get_schema('image')
jsonschema.validate(obj, schema) jsonschema.validate(obj, schema)
def create(self, request): def create(self, request):
@ -131,9 +132,9 @@ class ResponseSerializer(wsgi.JSONResponseSerializer):
response.status_int = 204 response.status_int = 204
def create_resource(conf): def create_resource(conf, schema_api):
"""Images resource factory method""" """Images resource factory method"""
deserializer = RequestDeserializer(conf) deserializer = RequestDeserializer(conf, schema_api)
serializer = ResponseSerializer() serializer = ResponseSerializer()
controller = ImagesController(conf) controller = ImagesController(conf)
return wsgi.Resource(controller, deserializer, serializer) 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 root
from glance.api.v2 import schemas from glance.api.v2 import schemas
from glance.common import wsgi from glance.common import wsgi
import glance.schema
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -38,10 +39,12 @@ class API(wsgi.Router):
self.conf = conf self.conf = conf
mapper = routes.Mapper() mapper = routes.Mapper()
schema_api = glance.schema.API()
root_resource = root.create_resource(conf) root_resource = root.create_resource(conf)
mapper.connect('/', controller=root_resource, action='index') mapper.connect('/', controller=root_resource, action='index')
schemas_resource = schemas.create_resource(conf) schemas_resource = schemas.create_resource(conf, schema_api)
mapper.connect('/schemas', mapper.connect('/schemas',
controller=schemas_resource, controller=schemas_resource,
action='index', action='index',
@ -55,7 +58,7 @@ class API(wsgi.Router):
action='access', action='access',
conditions={'method': ['GET']}) conditions={'method': ['GET']})
images_resource = images.create_resource(conf) images_resource = images.create_resource(conf, schema_api)
mapper.connect('/images', mapper.connect('/images',
controller=images_resource, controller=images_resource,
action='index', action='index',
@ -101,7 +104,7 @@ class API(wsgi.Router):
action='delete', action='delete',
conditions={'method': ['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', mapper.connect('/images/{image_id}/access',
controller=image_access_resource, controller=image_access_resource,
action='index', action='index',

View File

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

View File

@ -236,3 +236,7 @@ class RegionAmbiguity(GlanceException):
class WorkerCreationFailure(GlanceException): class WorkerCreationFailure(GlanceException):
message = _("Server worker creation failed: %(reason)s.") 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.api.v2 import image_access
from glance.common import exception from glance.common import exception
from glance.common import utils from glance.common import utils
import glance.schema
import glance.tests.unit.utils as test_utils import glance.tests.unit.utils as test_utils
@ -108,7 +109,8 @@ class TestImageAccessController(unittest.TestCase):
class TestImageAccessDeserializer(unittest.TestCase): class TestImageAccessDeserializer(unittest.TestCase):
def setUp(self): def setUp(self):
self.deserializer = image_access.RequestDeserializer({}) schema_api = glance.schema.API()
self.deserializer = image_access.RequestDeserializer({}, schema_api)
def test_create(self): def test_create(self):
fixture = { fixture = {

View File

@ -93,7 +93,9 @@ class TestImagesController(unittest.TestCase):
class TestImagesDeserializer(unittest.TestCase): class TestImagesDeserializer(unittest.TestCase):
def setUp(self): 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): def test_create(self):
request = test_utils.FakeRequest() request = test_utils.FakeRequest()

View File

@ -15,15 +15,17 @@
import unittest import unittest
import glance.api.v2.schemas from glance.api.v2 import schemas
import glance.tests.unit.utils as test_utils import glance.tests.unit.utils as test_utils
import glance.schema
class TestSchemasController(unittest.TestCase): class TestSchemasController(unittest.TestCase):
def setUp(self): def setUp(self):
super(TestSchemasController, self).setUp() 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): def test_index(self):
req = test_utils.FakeRequest() req = test_utils.FakeRequest()
@ -37,9 +39,9 @@ class TestSchemasController(unittest.TestCase):
def test_image(self): def test_image(self):
req = test_utils.FakeRequest() req = test_utils.FakeRequest()
output = self.controller.image(req) 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): def test_access(self):
req = test_utils.FakeRequest() req = test_utils.FakeRequest()
output = self.controller.access(req) output = self.controller.access(req)
self.assertEqual(glance.api.v2.schemas.ACCESS_SCHEMA, output) self.assertEqual(self.schema_api.get_schema('access'), output)