From eee5fecdf6f2853acb0909d077878274cd2ecd7b Mon Sep 17 00:00:00 2001 From: Brian Waldon Date: Fri, 4 May 2012 22:26:52 -0700 Subject: [PATCH] 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 --- etc/schema-access.json | 1 + etc/schema-image.json | 14 ++ glance/api/v2/image_access.py | 10 +- glance/api/v2/images.py | 9 +- glance/api/v2/router.py | 9 +- glance/api/v2/schemas.py | 49 +----- glance/common/exception.py | 4 + glance/schema.py | 80 +++++++++ glance/tests/functional/test_schema.py | 35 ++++ glance/tests/unit/test_schema.py | 162 ++++++++++++++++++ .../unit/v2/test_image_access_resource.py | 4 +- glance/tests/unit/v2/test_images_resource.py | 4 +- glance/tests/unit/v2/test_schemas_resource.py | 10 +- 13 files changed, 333 insertions(+), 58 deletions(-) create mode 100644 etc/schema-access.json create mode 100644 etc/schema-image.json create mode 100644 glance/schema.py create mode 100644 glance/tests/functional/test_schema.py create mode 100644 glance/tests/unit/test_schema.py diff --git a/etc/schema-access.json b/etc/schema-access.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/etc/schema-access.json @@ -0,0 +1 @@ +{} diff --git a/etc/schema-image.json b/etc/schema-image.json new file mode 100644 index 0000000000..bea32f9bee --- /dev/null +++ b/etc/schema-image.json @@ -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"] + }, +} diff --git a/glance/api/v2/image_access.py b/glance/api/v2/image_access.py index 42ddfeff83..854a9c8b7f 100644 --- a/glance/api/v2/image_access.py +++ b/glance/api/v2/image_access.py @@ -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) diff --git a/glance/api/v2/images.py b/glance/api/v2/images.py index 025b547eaa..f1479d67cd 100644 --- a/glance/api/v2/images.py +++ b/glance/api/v2/images.py @@ -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) diff --git a/glance/api/v2/router.py b/glance/api/v2/router.py index a95cf2c4ec..cc3362d897 100644 --- a/glance/api/v2/router.py +++ b/glance/api/v2/router.py @@ -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', diff --git a/glance/api/v2/schemas.py b/glance/api/v2/schemas.py index 151083a973..4cb303794d 100644 --- a/glance/api/v2/schemas.py +++ b/glance/api/v2/schemas.py @@ -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) diff --git a/glance/common/exception.py b/glance/common/exception.py index fd00aa3926..83d81ea811 100644 --- a/glance/common/exception.py +++ b/glance/common/exception.py @@ -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") diff --git a/glance/schema.py b/glance/schema.py new file mode 100644 index 0000000000..34f38187c7 --- /dev/null +++ b/glance/schema.py @@ -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 diff --git a/glance/tests/functional/test_schema.py b/glance/tests/functional/test_schema.py new file mode 100644 index 0000000000..032bd6ef44 --- /dev/null +++ b/glance/tests/functional/test_schema.py @@ -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()) diff --git a/glance/tests/unit/test_schema.py b/glance/tests/unit/test_schema.py new file mode 100644 index 0000000000..782e2f789d --- /dev/null +++ b/glance/tests/unit/test_schema.py @@ -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) diff --git a/glance/tests/unit/v2/test_image_access_resource.py b/glance/tests/unit/v2/test_image_access_resource.py index 9559bf02d7..5739d4c132 100644 --- a/glance/tests/unit/v2/test_image_access_resource.py +++ b/glance/tests/unit/v2/test_image_access_resource.py @@ -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 = { diff --git a/glance/tests/unit/v2/test_images_resource.py b/glance/tests/unit/v2/test_images_resource.py index 17fb106959..7960669a17 100644 --- a/glance/tests/unit/v2/test_images_resource.py +++ b/glance/tests/unit/v2/test_images_resource.py @@ -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() diff --git a/glance/tests/unit/v2/test_schemas_resource.py b/glance/tests/unit/v2/test_schemas_resource.py index b1532de0e8..e37826a9bd 100644 --- a/glance/tests/unit/v2/test_schemas_resource.py +++ b/glance/tests/unit/v2/test_schemas_resource.py @@ -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)