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:
parent
7553b4fcb6
commit
eee5fecdf6
1
etc/schema-access.json
Normal file
1
etc/schema-access.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
14
etc/schema-image.json
Normal file
14
etc/schema-image.json
Normal 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"]
|
||||
},
|
||||
}
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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',
|
||||
|
@ -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)
|
||||
|
@ -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
80
glance/schema.py
Normal 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
|
35
glance/tests/functional/test_schema.py
Normal file
35
glance/tests/functional/test_schema.py
Normal 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())
|
162
glance/tests/unit/test_schema.py
Normal file
162
glance/tests/unit/test_schema.py
Normal 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)
|
@ -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 = {
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user