From 09c301c28cc51c37636cc6a7ac9673f8f58f9c8c Mon Sep 17 00:00:00 2001 From: jinxingfang Date: Mon, 27 Mar 2017 09:37:30 +0800 Subject: [PATCH] provide-crud-api-operation Add abstract crud api for client. Change-Id: I545a8ab7af3791f9c5eda8c79370ea8e77fae26a --- valenceclient/common/base.py | 168 +++++++++++++++++ valenceclient/tests/unit/common/test_base.py | 189 +++++++++++++++++++ 2 files changed, 357 insertions(+) create mode 100644 valenceclient/common/base.py create mode 100644 valenceclient/tests/unit/common/test_base.py diff --git a/valenceclient/common/base.py b/valenceclient/common/base.py new file mode 100644 index 0000000..0eeabe1 --- /dev/null +++ b/valenceclient/common/base.py @@ -0,0 +1,168 @@ +# Copyright 2017 99cloud, Inc. +# 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. + +""" +Base utilities to build API operation managers and objects on top of +""" + +import abc +import copy +import six +from valenceclient.common.apiclient import base +from valenceclient import exc + + +def getid(obj): + """Wrapper to get object's ID + + Abstracts the common pattern of allowing both an object or an + object's ID (UUID) as a parameter when dealing with relationships. + """ + try: + return obj.id + except AttributeError: + return obj + + +@six.add_metaclass(abc.ABCMeta) +class Manager(object): + """Provides CRUD operations with a particular API.""" + + def __init__(self, api): + self.api = api + + def _path(self, resource_id=None): + """Return a request path for a given resource identifier. + + :param resource_id: identifier of the resource to generate the request + path + """ + return ('/v1/%s/%s' % (self._resource_name, resource_id) if + resource_id else '/v1/%s' % self._resource_name) + + @abc.abstractproperty + def resource_class(self): + """The resource class""" + + @abc.abstractproperty + def _resource_name(self): + """The resource name""" + + def _get(self, resource_id, fields=None): + """Retrieve a resource. + + :param resource_id: Identifier of the resource. + :param fields: List of specific fields to be returned. + """ + + if fields is not None: + resource_id = '/%s' % resource_id + resource_id += ','.join(fields) + + try: + return self._list(self._path(resource_id))[0] + except ValueError: + return None + + def _get_as_dict(self, resource_id, fields=None): + """Retrieve a resource as a dictionary + + :param resource_id: Identifier of the resource. + :param fields: List of specific fields to be returned. + :returns: a dictionary representing the resource; may be empty + """ + + resource = self._get(resource_id, fields=fields) + if resource: + return resource.to_dict() + else: + return {} + + def _list(self, url, obj_class=None,): + resp, body = self.api.json_request('GET', url) + if obj_class is None: + obj_class = self.resource_class + + if not isinstance(body, list): + body = [body] + + return [obj_class(self, res, loaded=True) for res in body if res] + + def _update(self, resource_id, patch, method='PATCH'): + """Update a resource. + + :param resource_id: Resource identifier. + :param patch: New version of a given resource. + :param method: Name of the method for the request. + """ + + url = self._path(resource_id) + resp, body = self.api.json_request(method, url, body=patch) + # PATCH/PUT requests may not return a body + if body: + return self.resource_class(self, body) + + def _delete(self, resource_id): + """Delete a resource. + + :param resource_id: Resource identifier. + """ + self.api.json_request('DELETE', self._path(resource_id)) + + +@six.add_metaclass(abc.ABCMeta) +class CreateManager(Manager): + """Provides creation operations with a particular API.""" + + @abc.abstractproperty + def _creation_attributes(self): + """A list of required creation attributes for a resource type""" + + def create(self, **kwargs): + """Create a resource based on a kwargs dictionary of attributes. + + :param kwargs: A dictionary containing the attributes of the resource + that will be created. + :raises exc.InvalidAttribution: For invalid attributes that are not + needed to create the resource. + """ + new = {} + invalid = [] + for (key, value) in kwargs.items(): + if key in self._creation_attributes: + new[key] = value + else: + invalid.append(key) + if invalid: + raise exc.InvalidAttribution( + 'The attribution(s) "%s(attrs)s" are invalid: they are not ' + 'needed to create %(resource)s.' % + {'resource': self._resource_name, + 'attrs': '","'.join(invalid)}) + + url = self._path() + resp, body = self.api.json_request('POST', url, body=new) + if body: + return self.resource_class(self, body) + + +class Resource(base.Resource): + """Represents a particular instance of an object (tenant, user, etc). + + This is pretty much just a bag for attributes. + """ + + def to_dict(self): + return copy.deepcopy(self._info) diff --git a/valenceclient/tests/unit/common/test_base.py b/valenceclient/tests/unit/common/test_base.py new file mode 100644 index 0000000..7745186 --- /dev/null +++ b/valenceclient/tests/unit/common/test_base.py @@ -0,0 +1,189 @@ +# Copyright 2017 99cloud Inc. +# 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 + +import mock +import testtools + +from valenceclient.common import base +from valenceclient.tests.unit import utils + + +TESTABLE_RESOURCE = { + 'uuid': '11111111-2222-3333-4444-555555555555', + 'attribute1': '1', + 'attribute2': '2', +} + +CREATE_TESTABLE_RESOURCE = copy.deepcopy(TESTABLE_RESOURCE) +del CREATE_TESTABLE_RESOURCE['uuid'] + +INVALID_ATTRIBUTE_TESTABLE_RESOURCE = { + 'non-existent-attribute': 'blablabla', + 'attribute1': '1', + 'attribute2': '2', +} + +UPDATED_TESTABLE_RESOURCE = copy.deepcopy(TESTABLE_RESOURCE) +NEW_ATTRIBUTE_VALUE = 'brand-new-attribute-value' +UPDATED_TESTABLE_RESOURCE['attribute1'] = NEW_ATTRIBUTE_VALUE + +fake_responses = { + '/redfish/v1/testableresources': + { + 'GET': ( + {}, + {"testableresources": [TESTABLE_RESOURCE]}, + ), + 'POST': ( + {}, + CREATE_TESTABLE_RESOURCE, + ), + }, + '/redfish/v1/testableresources/%s' % TESTABLE_RESOURCE['uuid']: + { + 'GET': ( + {}, + TESTABLE_RESOURCE, + ), + 'DELETE': ( + {}, + None, + ), + 'PATCH': ( + {}, + UPDATED_TESTABLE_RESOURCE, + ), + }, + +} + + +class TestableResource(base.Resource): + def __repr__(self): + return "" % self._info + + +class TestableManager(base.CreateManager): + resource_class = TestableResource + _creation_attributes = ['attribute1', 'attribute2'] + _resource_name = 'testableresources' + + def _path(self, id=None): + return ('/redfish/v1/testableresources/%s' % id if id + else '/redfish/v1/testableresources') + + def get(self, testable_resource_id, fields=None): + return self._get(resource_id=testable_resource_id, + fields=fields) + + def delete(self, testable_resource_id): + return self._delete(resource_id=testable_resource_id) + + def update(self, testable_resource_id, patch): + return self._update(resource_id=testable_resource_id, + patch=patch) + + +class ManagerTestCase(testtools.TestCase): + + def setUp(self): + super(ManagerTestCase, self).setUp() + self.api = utils.FakeAPI(fake_responses) + self.manager = TestableManager(self.api) + + def test_create(self): + resource = self.manager.create(**CREATE_TESTABLE_RESOURCE) + expect = [ + ('POST', + '/redfish/v1/testableresources', + {}, + CREATE_TESTABLE_RESOURCE), + ] + self.assertEqual(expect, self.api.calls) + self.assertTrue(resource) + self.assertIsInstance(resource, TestableResource) + + def test__get(self): + resource_id = TESTABLE_RESOURCE['uuid'] + resource = self.manager._get(resource_id) + expect = [ + ('GET', '/redfish/v1/testableresources/%s' % resource_id, + {}, None), + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(resource_id, resource.uuid) + self.assertEqual(TESTABLE_RESOURCE['attribute1'], resource.attribute1) + + def test__get_as_dict(self): + resource_id = TESTABLE_RESOURCE['uuid'] + resource = self.manager._get_as_dict(resource_id) + expect = [ + ('GET', '/redfish/v1/testableresources/%s' % resource_id, + {}, None), + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(TESTABLE_RESOURCE, resource) + + @mock.patch.object(base.Manager, '_get', autospec=True) + def test__get_as_dict_empty(self, mock_get): + mock_get.return_value = None + resource_id = TESTABLE_RESOURCE['uuid'] + resource = self.manager._get_as_dict(resource_id) + mock_get.assert_called_once_with(mock.ANY, resource_id, fields=None) + self.assertEqual({}, resource) + + def test_get(self): + resource = self.manager.get(TESTABLE_RESOURCE['uuid']) + expect = [ + ('GET', + '/redfish/v1/testableresources/%s' % TESTABLE_RESOURCE['uuid'], + {}, + None), + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(TESTABLE_RESOURCE['uuid'], resource.uuid) + self.assertEqual(TESTABLE_RESOURCE['attribute1'], resource.attribute1) + + def test_update(self): + patch = {'op': 'replace', + 'value': NEW_ATTRIBUTE_VALUE, + 'path': '/attribute1'} + resource = self.manager.update( + testable_resource_id=TESTABLE_RESOURCE['uuid'], + patch=patch + ) + expect = [ + ('PATCH', + '/redfish/v1/testableresources/%s' % TESTABLE_RESOURCE['uuid'], + {}, + patch), + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(NEW_ATTRIBUTE_VALUE, resource.attribute1) + + def test_delete(self): + resource = self.manager.delete( + testable_resource_id=TESTABLE_RESOURCE['uuid'] + ) + expect = [ + ('DELETE', + '/redfish/v1/testableresources/%s' % TESTABLE_RESOURCE['uuid'], + {}, + None), + ] + self.assertEqual(expect, self.api.calls) + self.assertIsNone(resource)