From c8105da07a6219eb3bb9d73fd197eb65317a6d33 Mon Sep 17 00:00:00 2001 From: Jamie Lennox Date: Wed, 9 Apr 2014 13:12:00 +1000 Subject: [PATCH] Add base resource class This has been describes as closer to the ORM approach whereby the resource contains all the information on how to communicate with a server. It is intended to be a relatively low level interface on which other things may be built. So I expect some helper wrappers will be built at a later time. The manager in this commit is no longer a requirement, but it shows how you can keep the current client pattern of managers and remove the manual handling of session objects. Change-Id: I46678215063c31928c0b372c039ed020bed837c9 --- openstack/resource.py | 296 ++++++++++++++++++++++++++++++ openstack/session.py | 6 +- openstack/tests/base.py | 47 +++++ openstack/tests/fakes.py | 12 ++ openstack/tests/test_resource.py | 152 +++++++++++++++ openstack/tests/test_session.py | 16 +- openstack/tests/test_transport.py | 132 +++++-------- openstack/transport.py | 10 + openstack/utils.py | 21 +++ 9 files changed, 592 insertions(+), 100 deletions(-) create mode 100644 openstack/resource.py create mode 100644 openstack/tests/test_resource.py create mode 100644 openstack/utils.py diff --git a/openstack/resource.py b/openstack/resource.py new file mode 100644 index 000000000..06f66885f --- /dev/null +++ b/openstack/resource.py @@ -0,0 +1,296 @@ +# 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 abc +import collections + +import six +from six.moves.urllib import parse as url_parse + +from openstack import utils + + +class MethodNotSupported(Exception): + """The resource does not support this operation type.""" + + +@six.add_metaclass(abc.ABCMeta) +class Resource(collections.MutableMapping): + """A base class that represents a remote resource. + + Attributes of the resource are defined by the responses from the server + rather than in code so that we don't have to try and keep up with all + possible attributes and extensions. This may be changed in the future. + + For update management we maintain a dirty list so when updating an object + only the attributes that have actually been changed are sent to the server. + + There is some support here for lazy loading that needs improvement. + """ + + # the singular and plural forms of the key element + resource_key = None + resources_key = None + + # the base part of the url for this resource + base_path = '' + + # The service this belongs to. e.g. 'identity' + # (unused, is a session/auth_plugin attribute for determining URL) + service = None + + # limit the abilities of a subclass. You should set these to true if your + # resource supports that function. + allow_create = False + allow_retrieve = False + allow_update = False + allow_delete = False + allow_list = False + + def __init__(self, attrs=None, loaded=False): + if attrs is None: + attrs = {} + + self._id = attrs.pop('id', None) + self._attrs = attrs + self._dirty = set() if loaded else set(attrs.keys()) + self._loaded = loaded + + ## + # CONSTRUCTORS + ## + + @classmethod + def new(cls, **kwargs): + """Create a new instance of this resource. + + Internally set flags such that it is marked as not present on the + server. + """ + return cls(kwargs, loaded=False) + + @classmethod + def existing(cls, **kwargs): + """Create a new object representation of an existing resource. + + It is marked as an exact replication of a resource present on a server. + """ + return cls(kwargs, loaded=True) + + ## + # MUTABLE MAPPING IMPLEMENTATION + ## + + def __getitem__(self, name): + return self._attrs[name] + + def __setitem__(self, name, value): + try: + orig = self._attrs[name] + except KeyError: + changed = True + else: + changed = orig != value + + if changed: + self._attrs[name] = value + self._dirty.add(name) + + def __delitem__(self, name): + del self._attrs[name] + self._dirty.add(name) + + def __len__(self): + return len(self._attrs) + + def __iter__(self): + return iter(self._attrs) + + ## + # BASE PROPERTIES/OPERATIONS + ## + + @property + def id(self): + # id is read only + return self._id + + @id.deleter + def id_del(self): + self._id = None + + @property + def is_dirty(self): + return len(self._dirty) > 0 + + def _reset_dirty(self): + self._dirty = set() + + ## + # CRUD OPERATIONS + ## + + @classmethod + def create_by_id(cls, session, attrs, r_id=None): + if not cls.allow_create: + raise MethodNotSupported('create') + + if cls.resource_key: + body = {cls.resource_key: attrs} + else: + body = attrs + + if r_id: + url = utils.urljoin(cls.base_path, r_id) + resp = cls._http_put(session, url, json=body) + else: + resp = cls._http_post(session, cls.base_path, json=body) + + resp_body = resp.json() + + if cls.resource_key: + resp_body = resp_body[cls.resource_key] + + return resp_body + + def create(self, session): + resp_body = self.create_by_id(session, self._attrs, self.id) + self._id = resp_body.pop('id') + self._reset_dirty() + + @classmethod + def get_data_by_id(cls, session, r_id): + if not cls.allow_retrieve: + raise MethodNotSupported('retrieve') + + url = utils.urljoin(cls.base_path, r_id) + body = cls._http_get(session, url).json() + + if cls.resource_key: + body = body[cls.resource_key] + + return body + + @classmethod + def get_by_id(cls, session, r_id): + body = cls.get_data_by_id(session, r_id) + return cls.existing(**body) + + def get(self, session): + body = self.get_data_by_id(session, self.id) + self._attrs.update(body) + self._loaded = True + + @classmethod + def update_by_id(cls, session, r_id, attrs): + if not cls.allow_update: + raise MethodNotSupported('update') + + if cls.resource_key: + body = {cls.resource_key: attrs} + else: + body = attrs + + url = utils.urljoin(cls.base_path, r_id) + resp_body = cls._http_patch(session, url, json=body).json() + + if cls.resource_key: + resp_body = resp_body[cls.resource_key] + + return resp_body + + def update(self, session): + if not self.is_dirty: + return + + dirty_attrs = dict((k, self._attrs[k]) for k in self._dirty) + resp_json = self.update_by_id(session, self.id, dirty_attrs) + + try: + resp_id = resp_json.pop('id') + except KeyError: + pass + else: + assert resp_id == self.id + + self._reset_dirty() + + @classmethod + def delete_by_id(cls, session, r_id): + if not cls.allow_delete: + raise MethodNotSupported('delete') + + cls._http_delete(session, utils.urljoin(cls.base_path, r_id)) + + def delete(self, session): + self.delete_by_id(session, self.id) + + @classmethod + def list(cls, session, limit=None, marker=None): + # NOTE(jamielennox): Is it possible we can return a generator from here + # and allow us to keep paging rather than limit and marker? + if not cls.allow_list: + raise MethodNotSupported('retrieve') + + filters = {} + + if limit: + filters['limit'] = limit + if marker: + filters['marker'] = marker + + url = cls.base_path + if filters: + url = '%s?%s' % (url, url_parse.urlencode(filters)) + + resp_body = cls._http_get(session, url).json() + + if cls.resources_key: + resp_body = resp_body[cls.resources_key] + + return [cls.existing(**data) for data in resp_body] + + ### + # HTTP Operations + ### + # these shouldn't live here long term + + @classmethod + def _http_request(cls, session, method, path, **kwargs): + headers = kwargs.setdefault('headers', {}) + headers['Accept'] = 'application/json' + + return session._request(cls.service, path, method, **kwargs) + + @classmethod + def _http_get(cls, session, url, **kwargs): + return cls._http_request(session, 'GET', url, **kwargs) + + @classmethod + def _http_post(cls, session, url, **kwargs): + return cls._http_request(session, 'POST', url, **kwargs) + + @classmethod + def _http_put(cls, session, url, **kwargs): + return cls._http_request(session, 'PUT', url, **kwargs) + + @classmethod + def _http_patch(cls, session, url, **kwargs): + return cls._http_request(session, 'PATCH', url, **kwargs) + + @classmethod + def _http_delete(cls, session, url, **kwargs): + return cls._http_request(session, 'DELETE', url, **kwargs) + + @classmethod + def _http_head(cls, session, url, **kwargs): + return cls._http_request(session, 'HEAD', url, **kwargs) diff --git a/openstack/session.py b/openstack/session.py index fcecf70ba..57ff82de9 100644 --- a/openstack/session.py +++ b/openstack/session.py @@ -12,6 +12,8 @@ import logging +from openstack import utils + _logger = logging.getLogger(__name__) @@ -53,7 +55,9 @@ class Session(object): if token: headers['X-Auth-Token'] = token - url = self.authenticator.get_endpoint(self.transport, service) + path + endpoint = self.authenticator.get_endpoint(self.transport, service) + url = utils.urljoin(endpoint, path) + return self.transport.request(method, url, **kwargs) def head(self, service, path, **kwargs): diff --git a/openstack/tests/base.py b/openstack/tests/base.py index 0b5b1f446..46d2f9cf8 100644 --- a/openstack/tests/base.py +++ b/openstack/tests/base.py @@ -15,11 +15,15 @@ # License for the specific language governing permissions and limitations # under the License. +import json import os import fixtures +import httpretty import testtools +from openstack import utils + _TRUE_VALUES = ('true', '1', 'yes') @@ -51,3 +55,46 @@ class TestCase(testtools.TestCase): self.useFixture(fixtures.MonkeyPatch('sys.stderr', stderr)) self.log_fixture = self.useFixture(fixtures.FakeLogger()) + + +class TestTransportBase(TestCase): + + TEST_URL = 'http://www.root.url' + + def stub_url(self, method, path=None, base_url=None, **kwargs): + if not base_url: + base_url = self.TEST_URL + + if isinstance(path, (list, tuple)): + base_url = utils.urljoin(base_url, *path) + elif path: + base_url = utils.urljoin(base_url, path) + + if 'json' in kwargs: + json_data = kwargs.pop('json') + if json_data is not None: + kwargs['body'] = json.dumps(json_data) + kwargs['Content-Type'] = 'application/json' + + httpretty.register_uri(method, base_url, **kwargs) + + def assertRequestHeaderEqual(self, name, val): + """Verify that the last request made contains a header and its value + + The request must have already been made and httpretty must have been + activated for the request. + + """ + headers = httpretty.last_request().headers + self.assertEqual(val, headers.get(name)) + + def assertResponseOK(self, resp, status=200, body=None): + """Verify the Response object contains expected values + + Tests our defaults for a successful request. + """ + + self.assertTrue(resp.ok) + self.assertEqual(status, resp.status_code) + if body: + self.assertEqual(body, resp.text) diff --git a/openstack/tests/fakes.py b/openstack/tests/fakes.py index 116bd8471..f979ded57 100644 --- a/openstack/tests/fakes.py +++ b/openstack/tests/fakes.py @@ -23,3 +23,15 @@ class FakeTransport(mock.Mock): super(FakeTransport, self).__init__() self.request = mock.Mock() self.request.return_value = self.RESPONSE + + +class FakeAuthenticator(mock.Mock): + TOKEN = 'fake_token' + ENDPOINT = 'http://www.example.com/endpoint' + + def __init__(self): + super(FakeAuthenticator, self).__init__() + self.get_token = mock.Mock() + self.get_token.return_value = self.TOKEN + self.get_endpoint = mock.Mock() + self.get_endpoint.return_value = self.ENDPOINT diff --git a/openstack/tests/test_resource.py b/openstack/tests/test_resource.py new file mode 100644 index 000000000..3a2c51870 --- /dev/null +++ b/openstack/tests/test_resource.py @@ -0,0 +1,152 @@ +# 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 httpretty + +from openstack import resource +from openstack import session +from openstack.tests import base +from openstack.tests import fakes +from openstack import transport + +fake_name = 'name' +fake_id = 99 +fake_attr1 = 'attr1' +fake_attr2 = 'attr2' + +fake_resource = 'fake' +fake_resources = 'fakes' +fake_path = fake_resources + +fake_data = {'id': fake_id, + 'name': fake_name, + 'attr1': fake_attr1, + 'attr2': fake_attr2} +fake_body = {fake_resource: fake_data} + + +class FakeResource(resource.Resource): + + resource_key = fake_resource + resources_key = fake_path + base_path = '/%s' % fake_path + + allow_create = allow_retrieve = allow_update = True + allow_delete = allow_list = True + + +class ResourceTests(base.TestTransportBase): + + TEST_URL = fakes.FakeAuthenticator.ENDPOINT + + def setUp(self): + super(ResourceTests, self).setUp() + self.transport = transport.Transport() + self.auth = fakes.FakeAuthenticator() + self.session = session.Session(self.transport, self.auth) + + @httpretty.activate + def test_create(self): + self.stub_url(httpretty.POST, path=fake_path, json=fake_body) + + obj = FakeResource.new(name=fake_name, + attr1=fake_attr1, attr2=fake_attr2) + obj.create(self.session) + self.assertFalse(obj.is_dirty) + + last_req = httpretty.last_request().parsed_body[fake_resource] + + self.assertEqual(3, len(last_req)) + self.assertEqual(fake_name, last_req['name']) + self.assertEqual(fake_attr1, last_req['attr1']) + self.assertEqual(fake_attr2, last_req['attr2']) + + self.assertEqual(fake_id, obj.id) + self.assertEqual(fake_name, obj['name']) + self.assertEqual(fake_attr1, obj['attr1']) + self.assertEqual(fake_attr2, obj['attr2']) + + @httpretty.activate + def test_get(self): + self.stub_url(httpretty.GET, path=[fake_path, fake_id], json=fake_body) + obj = FakeResource.get_by_id(self.session, fake_id) + + self.assertEqual(fake_name, obj['name']) + self.assertEqual(fake_id, obj.id) + self.assertEqual(fake_attr1, obj['attr1']) + self.assertEqual(fake_attr2, obj['attr2']) + + @httpretty.activate + def test_update(self): + new_attr1 = 'attr5' + fake_body1 = fake_body.copy() + fake_body1[fake_resource]['attr1'] = new_attr1 + + self.stub_url(httpretty.POST, path=fake_path, json=fake_body1) + self.stub_url(httpretty.PATCH, + path=[fake_path, fake_id], + json=fake_body) + + obj = FakeResource.new(name=fake_name, + attr1=new_attr1, + attr2=fake_attr2) + obj.create(self.session) + self.assertFalse(obj.is_dirty) + self.assertEqual(new_attr1, obj['attr1']) + + obj['attr1'] = fake_attr1 + self.assertTrue(obj.is_dirty) + + obj.update(self.session) + self.assertFalse(obj.is_dirty) + + last_req = httpretty.last_request().parsed_body[fake_resource] + self.assertEqual(1, len(last_req)) + self.assertEqual(fake_attr1, last_req['attr1']) + + self.assertEqual(fake_id, obj.id) + self.assertEqual(fake_name, obj['name']) + self.assertEqual(fake_attr1, obj['attr1']) + self.assertEqual(fake_attr2, obj['attr2']) + + @httpretty.activate + def test_delete(self): + self.stub_url(httpretty.GET, path=[fake_path, fake_id], json=fake_body) + self.stub_url(httpretty.DELETE, [fake_path, fake_id]) + obj = FakeResource.get_by_id(self.session, fake_id) + + obj.delete(self.session) + + last_req = httpretty.last_request() + self.assertEqual('DELETE', last_req.method) + self.assertEqual('/endpoint/%s/%s' % (fake_path, fake_id), + last_req.path) + + @httpretty.activate + def test_list(self): + results = [fake_data.copy(), fake_data.copy(), fake_data.copy()] + for i in range(len(results)): + results[i]['id'] = fake_id + i + + self.stub_url(httpretty.GET, + path=fake_path, + json={fake_resources: results}) + + objs = FakeResource.list(self.session, marker='x') + + self.assertIn('marker=x', httpretty.last_request().path) + self.assertEqual(3, len(objs)) + + for obj in objs: + self.assertIn(obj.id, range(fake_id, fake_id + 3)) + self.assertEqual(fake_name, obj['name']) + self.assertIsInstance(obj, FakeResource) diff --git a/openstack/tests/test_session.py b/openstack/tests/test_session.py index 930a274c3..eb7040855 100644 --- a/openstack/tests/test_session.py +++ b/openstack/tests/test_session.py @@ -10,26 +10,12 @@ # License for the specific language governing permissions and limitations # under the License. -import mock - from openstack.auth import service from openstack import session from openstack.tests import base from openstack.tests import fakes -class FakeAuthenticator(mock.Mock): - TOKEN = 'fake_token' - ENDPOINT = 'http://www.example.com/endpoint' - - def __init__(self): - super(FakeAuthenticator, self).__init__() - self.get_token = mock.Mock() - self.get_token.return_value = self.TOKEN - self.get_endpoint = mock.Mock() - self.get_endpoint.return_value = self.ENDPOINT - - class TestSession(base.TestCase): TEST_PATH = '/test/path' @@ -37,7 +23,7 @@ class TestSession(base.TestCase): def setUp(self): super(TestSession, self).setUp() self.xport = fakes.FakeTransport() - self.auth = FakeAuthenticator() + self.auth = fakes.FakeAuthenticator() self.serv = service.ServiceIdentifier('identity') self.sess = session.Session(self.xport, self.auth) self.expected = {'headers': {'X-Auth-Token': self.auth.TOKEN}} diff --git a/openstack/tests/test_transport.py b/openstack/tests/test_transport.py index 8a980c131..6abcda639 100644 --- a/openstack/tests/test_transport.py +++ b/openstack/tests/test_transport.py @@ -23,7 +23,6 @@ from openstack.tests import base from openstack import transport -fake_url = 'http://www.root.url' fake_request = 'Now is the time...' fake_response = 'for the quick brown fox...' fake_redirect = 'redirect text' @@ -41,48 +40,13 @@ fake_record2 = { } -class TestTransportBase(base.TestCase): - - def stub_url(self, method, base_url=None, **kwargs): - if not base_url: - base_url = fake_url - - if 'json' in kwargs: - json_data = kwargs.pop('json') - if json_data is not None: - kwargs['body'] = json.dumps(json_data) - kwargs['Content-Type'] = 'application/json' - - httpretty.register_uri(method, base_url, **kwargs) - - def assertRequestHeaderEqual(self, name, val): - """Verify that the last request made contains a header and its value - - The request must have already been made and httpretty must have been - activated for the request. - - """ - headers = httpretty.last_request().headers - self.assertEqual(val, headers.get(name)) - - def assertResponseOK(self, resp, status=200, body=fake_response): - """Verify the Response object contains expected values - - Tests our defaults for a successful request. - """ - - self.assertTrue(resp.ok) - self.assertEqual(status, resp.status_code) - self.assertEqual(body, resp.text) - - -class TestTransport(TestTransportBase): +class TestTransport(base.TestTransportBase): @httpretty.activate def test_request(self): self.stub_url(httpretty.GET, body=fake_response) sess = transport.Transport() - resp = sess.request('GET', fake_url) + resp = sess.request('GET', self.TEST_URL) self.assertEqual(httpretty.GET, httpretty.last_request().method) self.assertResponseOK(resp, body=fake_response) @@ -90,7 +54,7 @@ class TestTransport(TestTransportBase): def test_request_json(self): self.stub_url(httpretty.GET, json=fake_record1) sess = transport.Transport() - resp = sess.request('GET', fake_url) + resp = sess.request('GET', self.TEST_URL) self.assertEqual(httpretty.GET, httpretty.last_request().method) self.assertResponseOK(resp, body=json.dumps(fake_record1)) self.assertEqual(fake_record1, resp.json()) @@ -99,7 +63,7 @@ class TestTransport(TestTransportBase): def test_delete(self): self.stub_url(httpretty.DELETE, body=fake_response) sess = transport.Transport() - resp = sess.delete(fake_url) + resp = sess.delete(self.TEST_URL) self.assertEqual(httpretty.DELETE, httpretty.last_request().method) self.assertResponseOK(resp, body=fake_response) @@ -107,7 +71,7 @@ class TestTransport(TestTransportBase): def test_get(self): self.stub_url(httpretty.GET, body=fake_response) sess = transport.Transport() - resp = sess.get(fake_url) + resp = sess.get(self.TEST_URL) self.assertEqual(httpretty.GET, httpretty.last_request().method) self.assertResponseOK(resp, body=fake_response) @@ -115,7 +79,7 @@ class TestTransport(TestTransportBase): def test_head(self): self.stub_url(httpretty.HEAD, body=fake_response) sess = transport.Transport() - resp = sess.head(fake_url) + resp = sess.head(self.TEST_URL) self.assertEqual(httpretty.HEAD, httpretty.last_request().method) self.assertResponseOK(resp, body='') @@ -123,7 +87,7 @@ class TestTransport(TestTransportBase): def test_options(self): self.stub_url(httpretty.OPTIONS, body=fake_response) sess = transport.Transport() - resp = sess.options(fake_url) + resp = sess.options(self.TEST_URL) self.assertEqual(httpretty.OPTIONS, httpretty.last_request().method) self.assertResponseOK(resp, body=fake_response) @@ -131,7 +95,7 @@ class TestTransport(TestTransportBase): def test_patch(self): self.stub_url(httpretty.PATCH, body=fake_response) sess = transport.Transport() - resp = sess.patch(fake_url, json=fake_record2) + resp = sess.patch(self.TEST_URL, json=fake_record2) self.assertEqual(httpretty.PATCH, httpretty.last_request().method) self.assertEqual( json.dumps(fake_record2), @@ -143,7 +107,7 @@ class TestTransport(TestTransportBase): def test_post(self): self.stub_url(httpretty.POST, body=fake_response) sess = transport.Transport() - resp = sess.post(fake_url, json=fake_record2) + resp = sess.post(self.TEST_URL, json=fake_record2) self.assertEqual(httpretty.POST, httpretty.last_request().method) self.assertEqual( json.dumps(fake_record2), @@ -156,7 +120,7 @@ class TestTransport(TestTransportBase): self.stub_url(httpretty.PUT, body=fake_response) sess = transport.Transport() - resp = sess.put(fake_url, data=fake_request) + resp = sess.put(self.TEST_URL, data=fake_request) self.assertEqual(httpretty.PUT, httpretty.last_request().method) self.assertEqual( fake_request, @@ -164,7 +128,7 @@ class TestTransport(TestTransportBase): ) self.assertResponseOK(resp, body=fake_response) - resp = sess.put(fake_url, json=fake_record2) + resp = sess.put(self.TEST_URL, json=fake_record2) self.assertEqual(httpretty.PUT, httpretty.last_request().method) self.assertEqual( json.dumps(fake_record2), @@ -177,39 +141,39 @@ class TestTransport(TestTransportBase): self.stub_url(httpretty.GET, body=fake_response) sess = transport.Transport() - resp = sess.get(fake_url) + resp = sess.get(self.TEST_URL) self.assertTrue(resp.ok) self.assertRequestHeaderEqual( 'User-Agent', transport.DEFAULT_USER_AGENT, ) - resp = sess.get(fake_url, headers={'User-Agent': None}) + resp = sess.get(self.TEST_URL, headers={'User-Agent': None}) self.assertTrue(resp.ok) self.assertRequestHeaderEqual('User-Agent', None) - resp = sess.get(fake_url, user_agent=None) + resp = sess.get(self.TEST_URL, user_agent=None) self.assertTrue(resp.ok) self.assertRequestHeaderEqual('User-Agent', None) - resp = sess.get(fake_url, headers={'User-Agent': ''}) + resp = sess.get(self.TEST_URL, headers={'User-Agent': ''}) self.assertTrue(resp.ok) self.assertRequestHeaderEqual('User-Agent', '') - resp = sess.get(fake_url, user_agent='') + resp = sess.get(self.TEST_URL, user_agent='') self.assertTrue(resp.ok) self.assertRequestHeaderEqual('User-Agent', '') - resp = sess.get(fake_url, headers={'User-Agent': 'new-agent'}) + resp = sess.get(self.TEST_URL, headers={'User-Agent': 'new-agent'}) self.assertTrue(resp.ok) self.assertRequestHeaderEqual('User-Agent', 'new-agent') - resp = sess.get(fake_url, user_agent='new-agent') + resp = sess.get(self.TEST_URL, user_agent='new-agent') self.assertTrue(resp.ok) self.assertRequestHeaderEqual('User-Agent', 'new-agent') resp = sess.get( - fake_url, + self.TEST_URL, headers={'User-Agent': 'new-agent'}, user_agent=None, ) @@ -217,7 +181,7 @@ class TestTransport(TestTransportBase): self.assertRequestHeaderEqual('User-Agent', None) resp = sess.get( - fake_url, + self.TEST_URL, headers={'User-Agent': None}, user_agent='overrides-agent', ) @@ -225,7 +189,7 @@ class TestTransport(TestTransportBase): self.assertRequestHeaderEqual('User-Agent', 'overrides-agent') resp = sess.get( - fake_url, + self.TEST_URL, headers={'User-Agent': 'new-agent'}, user_agent='overrides-agent', ) @@ -237,39 +201,39 @@ class TestTransport(TestTransportBase): self.stub_url(httpretty.GET, body=fake_response) sess = transport.Transport(user_agent=None) - resp = sess.get(fake_url) + resp = sess.get(self.TEST_URL) self.assertTrue(resp.ok) self.assertRequestHeaderEqual( 'User-Agent', transport.DEFAULT_USER_AGENT, ) - resp = sess.get(fake_url, headers={'User-Agent': None}) + resp = sess.get(self.TEST_URL, headers={'User-Agent': None}) self.assertTrue(resp.ok) self.assertRequestHeaderEqual('User-Agent', None) - resp = sess.get(fake_url, user_agent=None) + resp = sess.get(self.TEST_URL, user_agent=None) self.assertTrue(resp.ok) self.assertRequestHeaderEqual('User-Agent', None) - resp = sess.get(fake_url, headers={'User-Agent': ''}) + resp = sess.get(self.TEST_URL, headers={'User-Agent': ''}) self.assertTrue(resp.ok) self.assertRequestHeaderEqual('User-Agent', '') - resp = sess.get(fake_url, user_agent='') + resp = sess.get(self.TEST_URL, user_agent='') self.assertTrue(resp.ok) self.assertRequestHeaderEqual('User-Agent', '') - resp = sess.get(fake_url, headers={'User-Agent': 'new-agent'}) + resp = sess.get(self.TEST_URL, headers={'User-Agent': 'new-agent'}) self.assertTrue(resp.ok) self.assertRequestHeaderEqual('User-Agent', 'new-agent') - resp = sess.get(fake_url, user_agent='new-agent') + resp = sess.get(self.TEST_URL, user_agent='new-agent') self.assertTrue(resp.ok) self.assertRequestHeaderEqual('User-Agent', 'new-agent') resp = sess.get( - fake_url, + self.TEST_URL, headers={'User-Agent': 'new-agent'}, user_agent=None, ) @@ -277,7 +241,7 @@ class TestTransport(TestTransportBase): self.assertRequestHeaderEqual('User-Agent', None) resp = sess.get( - fake_url, + self.TEST_URL, headers={'User-Agent': None}, user_agent='overrides-agent', ) @@ -285,7 +249,7 @@ class TestTransport(TestTransportBase): self.assertRequestHeaderEqual('User-Agent', 'overrides-agent') resp = sess.get( - fake_url, + self.TEST_URL, headers={'User-Agent': 'new-agent'}, user_agent='overrides-agent', ) @@ -297,36 +261,36 @@ class TestTransport(TestTransportBase): self.stub_url(httpretty.GET, body=fake_response) sess = transport.Transport(user_agent='test-agent') - resp = sess.get(fake_url) + resp = sess.get(self.TEST_URL) self.assertTrue(resp.ok) self.assertRequestHeaderEqual('User-Agent', 'test-agent') - resp = sess.get(fake_url, headers={'User-Agent': None}) + resp = sess.get(self.TEST_URL, headers={'User-Agent': None}) self.assertTrue(resp.ok) self.assertRequestHeaderEqual('User-Agent', None) - resp = sess.get(fake_url, user_agent=None) + resp = sess.get(self.TEST_URL, user_agent=None) self.assertTrue(resp.ok) self.assertRequestHeaderEqual('User-Agent', None) - resp = sess.get(fake_url, headers={'User-Agent': ''}) + resp = sess.get(self.TEST_URL, headers={'User-Agent': ''}) self.assertTrue(resp.ok) self.assertRequestHeaderEqual('User-Agent', '') - resp = sess.get(fake_url, user_agent='') + resp = sess.get(self.TEST_URL, user_agent='') self.assertTrue(resp.ok) self.assertRequestHeaderEqual('User-Agent', '') - resp = sess.get(fake_url, headers={'User-Agent': 'new-agent'}) + resp = sess.get(self.TEST_URL, headers={'User-Agent': 'new-agent'}) self.assertTrue(resp.ok) self.assertRequestHeaderEqual('User-Agent', 'new-agent') - resp = sess.get(fake_url, user_agent='new-agent') + resp = sess.get(self.TEST_URL, user_agent='new-agent') self.assertTrue(resp.ok) self.assertRequestHeaderEqual('User-Agent', 'new-agent') resp = sess.get( - fake_url, + self.TEST_URL, headers={'User-Agent': 'new-agent'}, user_agent=None, ) @@ -334,7 +298,7 @@ class TestTransport(TestTransportBase): self.assertRequestHeaderEqual('User-Agent', None) resp = sess.get( - fake_url, + self.TEST_URL, headers={'User-Agent': None}, user_agent='overrides-agent', ) @@ -342,7 +306,7 @@ class TestTransport(TestTransportBase): self.assertRequestHeaderEqual('User-Agent', 'overrides-agent') resp = sess.get( - fake_url, + self.TEST_URL, headers={'User-Agent': 'new-agent'}, user_agent='overrides-agent', ) @@ -374,7 +338,7 @@ class TestTransport(TestTransportBase): sess = transport.Transport() self.stub_url(httpretty.GET, status=404) - resp = sess.get(fake_url) + resp = sess.get(self.TEST_URL) self.assertFalse(resp.ok) self.assertEqual(404, resp.status_code) @@ -383,12 +347,12 @@ class TestTransport(TestTransportBase): sess = transport.Transport() self.stub_url(httpretty.GET, status=500) - resp = sess.get(fake_url) + resp = sess.get(self.TEST_URL) self.assertFalse(resp.ok) self.assertEqual(500, resp.status_code) -class TestTransportDebug(TestTransportBase): +class TestTransportDebug(base.TestTransportBase): def setUp(self): super(TestTransportDebug, self).setUp() @@ -410,7 +374,7 @@ class TestTransportDebug(TestTransportBase): 'ssh_config_dir': '~/myusername/.ssh', } resp = sess.post( - fake_url, + self.TEST_URL, headers=headers, params=params, json=fake_record2, @@ -440,7 +404,7 @@ class TestTransportDebug(TestTransportBase): self.assertIn(v, self.log_fixture.output) -class TestTransportRedirects(TestTransportBase): +class TestTransportRedirects(base.TestTransportBase): REDIRECT_CHAIN = [ 'http://myhost:3445/', @@ -476,14 +440,14 @@ class TestTransportRedirects(TestTransportBase): self.setup_redirects() sess = transport.Transport() resp = sess.get(self.REDIRECT_CHAIN[-2]) - self.assertResponseOK(resp) + self.assertResponseOK(resp, body=fake_response) @httpretty.activate def test_post_keeps_correct_method(self): self.setup_redirects(method=httpretty.POST, status=301) sess = transport.Transport() resp = sess.post(self.REDIRECT_CHAIN[-2]) - self.assertResponseOK(resp) + self.assertResponseOK(resp, body=fake_response) @httpretty.activate def test_redirect_forever(self): diff --git a/openstack/transport.py b/openstack/transport.py index ee32235d5..f6c1eae27 100644 --- a/openstack/transport.py +++ b/openstack/transport.py @@ -46,6 +46,7 @@ class Transport(requests.Session): user_agent=None, verify=True, redirect=DEFAULT_REDIRECT_LIMIT, + base_url=None, ): """Wraps requests.Session to add some OpenStack-specific features @@ -76,6 +77,10 @@ class Transport(requests.Session): self.verify = verify self._redirect = redirect + # NOTE(jamielennox): This is a stub for having auth plugins and + # discovery determine the correct base URL + self.base_url = base_url + def request(self, method, url, redirect=None, **kwargs): """Send a request @@ -100,6 +105,11 @@ class Transport(requests.Session): headers = kwargs.setdefault('headers', {}) + # NOTE(jamielennox): This is a stub for having auth plugins and + # discovery determine the correct base URL + if self.base_url: + url = "%s/%s" % (self.base_url.rstrip('/'), url.lstrip('/')) + # JSON-encode the data in json arg if present # Overwrites any existing 'data' value json_data = kwargs.pop('json', None) diff --git a/openstack/utils.py b/openstack/utils.py new file mode 100644 index 000000000..26f1fa12d --- /dev/null +++ b/openstack/utils.py @@ -0,0 +1,21 @@ +# 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. + + +def urljoin(*args): + """A custom version of urljoin that simply joins strings into a path. + + The real urljoin takes into account web semantics like when joining a url + like /path this should be joined to http://host/path as it is an anchored + link. We generally won't care about that in client. + """ + return '/'.join(str(a).strip('/') for a in args)