summaryrefslogtreecommitdiffstats
path: root/openstack/common
diff options
context:
space:
mode:
authorAlessio Ababilov <aababilov@griddynamics.com>2013-05-22 11:46:07 +0300
committerAlessio Ababilov <aababilo@yahoo-inc.com>2013-07-22 20:29:03 +0300
commit062cc24f5dfc8e1652a589f6a2f45ce8f3a1b89c (patch)
treefe04d2c1f4f6aadcc8416801a83dab472ea7ec52 /openstack/common
parentcd78a6dbd48c346aabbc5554386d42ac5a4a5771 (diff)
downloadoslo-062cc24f5dfc8e1652a589f6a2f45ce8f3a1b89c.tar.gz
oslo-062cc24f5dfc8e1652a589f6a2f45ce8f3a1b89c.tar.xz
oslo-062cc24f5dfc8e1652a589f6a2f45ce8f3a1b89c.zip
Implement apiclient library
This library can be used in novaclient, keystoneclient, glanceclient, and other client projects. The library contains common code and uses python-requests for HTTP communication. Features: * reissue authentication request for expired tokens; * pluggable authentication; * rich exceptions hierarchy. This code partially comes from: * python-keystoneclient/keystoneclient/base.py; * python-novaclient/novaclient/auth_plugin.py; * python-novaclient/novaclient/extension.py; * python-novaclient/tests/fakes.py. Partially implements: blueprint common-client-library Change-Id: Ic8b466a57554018092c31c6d6b3ea62f181d7cef
Diffstat (limited to 'openstack/common')
-rw-r--r--openstack/common/apiclient/auth.py227
-rw-r--r--openstack/common/apiclient/base.py492
-rw-r--r--openstack/common/apiclient/client.py360
-rw-r--r--openstack/common/apiclient/exceptions.py42
-rw-r--r--openstack/common/apiclient/fake_client.py172
5 files changed, 1272 insertions, 21 deletions
diff --git a/openstack/common/apiclient/auth.py b/openstack/common/apiclient/auth.py
new file mode 100644
index 0000000..1744228
--- /dev/null
+++ b/openstack/common/apiclient/auth.py
@@ -0,0 +1,227 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2013 OpenStack Foundation
+# Copyright 2013 Spanish National Research Council.
+# 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.
+
+# E0202: An attribute inherited from %s hide this method
+# pylint: disable=E0202
+
+import abc
+import argparse
+import logging
+import os
+
+from stevedore import extension
+
+from openstack.common.apiclient import exceptions
+
+
+logger = logging.getLogger(__name__)
+
+
+_discovered_plugins = {}
+
+
+def discover_auth_systems():
+ """Discover the available auth-systems.
+
+ This won't take into account the old style auth-systems.
+ """
+ global _discovered_plugins
+ _discovered_plugins = {}
+
+ def add_plugin(ext):
+ _discovered_plugins[ext.name] = ext.plugin
+
+ ep_namespace = "openstack.common.apiclient.auth"
+ mgr = extension.ExtensionManager(ep_namespace)
+ mgr.map(add_plugin)
+
+
+def load_auth_system_opts(parser):
+ """Load options needed by the available auth-systems into a parser.
+
+ This function will try to populate the parser with options from the
+ available plugins.
+ """
+ group = parser.add_argument_group("Common auth options")
+ BaseAuthPlugin.add_common_opts(group)
+ for name, auth_plugin in _discovered_plugins.iteritems():
+ group = parser.add_argument_group(
+ "Auth-system '%s' options" % name,
+ conflict_handler="resolve")
+ auth_plugin.add_opts(group)
+
+
+def load_plugin(auth_system):
+ try:
+ plugin_class = _discovered_plugins[auth_system]
+ except KeyError:
+ raise exceptions.AuthSystemNotFound(auth_system)
+ return plugin_class(auth_system=auth_system)
+
+
+def load_plugin_from_args(args):
+ """Load requred plugin and populate it with options.
+
+ Try to guess auth system if it is not specified. Systems are tried in
+ alphabetical order.
+
+ :type args: argparse.Namespace
+ :raises: AuthorizationFailure
+ """
+ auth_system = args.os_auth_system
+ if auth_system:
+ plugin = load_plugin(auth_system)
+ plugin.parse_opts(args)
+ plugin.sufficient_options()
+ return plugin
+
+ for plugin_auth_system in sorted(_discovered_plugins.iterkeys()):
+ plugin_class = _discovered_plugins[plugin_auth_system]
+ plugin = plugin_class()
+ plugin.parse_opts(args)
+ try:
+ plugin.sufficient_options()
+ except exceptions.AuthPluginOptionsMissing:
+ continue
+ return plugin
+ raise exceptions.AuthPluginOptionsMissing(["auth_system"])
+
+
+class BaseAuthPlugin(object):
+ """Base class for authentication plugins.
+
+ An authentication plugin needs to override at least the authenticate
+ method to be a valid plugin.
+ """
+
+ __metaclass__ = abc.ABCMeta
+
+ auth_system = None
+ opt_names = []
+ common_opt_names = [
+ "auth_system",
+ "username",
+ "password",
+ "tenant_name",
+ "token",
+ "auth_url",
+ ]
+
+ def __init__(self, auth_system=None, **kwargs):
+ self.auth_system = auth_system or self.auth_system
+ self.opts = dict((name, kwargs.get(name))
+ for name in self.opt_names)
+
+ @staticmethod
+ def _parser_add_opt(parser, opt):
+ """Add an option to parser in two variants.
+
+ :param opt: option name (with underscores)
+ """
+ dashed_opt = opt.replace("_", "-")
+ env_var = "OS_%s" % opt.upper()
+ arg_default = os.environ.get(env_var, "")
+ arg_help = "Defaults to env[%s]." % env_var
+ parser.add_argument(
+ "--os-%s" % dashed_opt,
+ metavar="<%s>" % dashed_opt,
+ default=arg_default,
+ help=arg_help)
+ parser.add_argument(
+ "--os_%s" % opt,
+ metavar="<%s>" % dashed_opt,
+ help=argparse.SUPPRESS)
+
+ @classmethod
+ def add_opts(cls, parser):
+ """Populate the parser with the options for this plugin.
+ """
+ for opt in cls.opt_names:
+ # use `BaseAuthPlugin.common_opt_names` since it is never
+ # changed in child classes
+ if opt not in BaseAuthPlugin.common_opt_names:
+ cls._parser_add_opt(parser, opt)
+
+ @classmethod
+ def add_common_opts(cls, parser):
+ """Add options that are common for several plugins.
+ """
+ for opt in cls.common_opt_names:
+ cls._parser_add_opt(parser, opt)
+
+ @staticmethod
+ def get_opt(opt_name, args):
+ """Return option name and value.
+
+ :param opt_name: name of the option, e.g., "username"
+ :param args: parsed arguments
+ """
+ return (opt_name, getattr(args, "os_%s" % opt_name, None))
+
+ def parse_opts(self, args):
+ """Parse the actual auth-system options if any.
+
+ This method is expected to populate the attribute `self.opts` with a
+ dict containing the options and values needed to make authentication.
+ """
+ self.opts.update(dict(self.get_opt(opt_name, args)
+ for opt_name in self.opt_names))
+
+ def authenticate(self, http_client):
+ """Authenticate using plugin defined method.
+
+ The method usually analyses `self.opts` and performs
+ a request to authentication server.
+
+ :param http_client: client object that needs authentication
+ :type http_client: HTTPClient
+ :raises: AuthorizationFailure
+ """
+ self.sufficient_options()
+ self._do_authenticate(http_client)
+
+ @abc.abstractmethod
+ def _do_authenticate(self, http_client):
+ """Protected method for authentication.
+ """
+
+ def sufficient_options(self):
+ """Check if all required options are present.
+
+ :raises: AuthPluginOptionsMissing
+ """
+ missing = [opt
+ for opt in self.opt_names
+ if not self.opts.get(opt)]
+ if missing:
+ raise exceptions.AuthPluginOptionsMissing(missing)
+
+ @abc.abstractmethod
+ def token_and_endpoint(self, endpoint_type, service_type):
+ """Return token and endpoint.
+
+ :param service_type: Service type of the endpoint
+ :type service_type: string
+ :param endpoint_type: Type of endpoint.
+ Possible values: public or publicURL,
+ internal or internalURL,
+ admin or adminURL
+ :type endpoint_type: string
+ :returns: tuple of token and endpoint strings
+ :raises: EndpointException
+ """
diff --git a/openstack/common/apiclient/base.py b/openstack/common/apiclient/base.py
new file mode 100644
index 0000000..0ecf1f5
--- /dev/null
+++ b/openstack/common/apiclient/base.py
@@ -0,0 +1,492 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2010 Jacob Kaplan-Moss
+# Copyright 2011 OpenStack LLC
+# Copyright 2012 Grid Dynamics
+# Copyright 2013 OpenStack Foundation
+# 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.
+"""
+
+# E1102: %s is not callable
+# pylint: disable=E1102
+
+import abc
+import urllib
+
+from openstack.common.apiclient import exceptions
+from openstack.common import strutils
+
+
+def getid(obj):
+ """Return id if argument is a Resource.
+
+ Abstracts the common pattern of allowing both an object or an object's ID
+ (UUID) as a parameter when dealing with relationships.
+ """
+ try:
+ if obj.uuid:
+ return obj.uuid
+ except AttributeError:
+ pass
+ try:
+ return obj.id
+ except AttributeError:
+ return obj
+
+
+# TODO(aababilov): call run_hooks() in HookableMixin's child classes
+class HookableMixin(object):
+ """Mixin so classes can register and run hooks."""
+ _hooks_map = {}
+
+ @classmethod
+ def add_hook(cls, hook_type, hook_func):
+ """Add a new hook of specified type.
+
+ :param cls: class that registers hooks
+ :param hook_type: hook type, e.g., '__pre_parse_args__'
+ :param hook_func: hook function
+ """
+ if hook_type not in cls._hooks_map:
+ cls._hooks_map[hook_type] = []
+
+ cls._hooks_map[hook_type].append(hook_func)
+
+ @classmethod
+ def run_hooks(cls, hook_type, *args, **kwargs):
+ """Run all hooks of specified type.
+
+ :param cls: class that registers hooks
+ :param hook_type: hook type, e.g., '__pre_parse_args__'
+ :param **args: args to be passed to every hook function
+ :param **kwargs: kwargs to be passed to every hook function
+ """
+ hook_funcs = cls._hooks_map.get(hook_type) or []
+ for hook_func in hook_funcs:
+ hook_func(*args, **kwargs)
+
+
+class BaseManager(HookableMixin):
+ """Basic manager type providing common operations.
+
+ Managers interact with a particular type of API (servers, flavors, images,
+ etc.) and provide CRUD operations for them.
+ """
+ resource_class = None
+
+ def __init__(self, client):
+ """Initializes BaseManager with `client`.
+
+ :param client: instance of BaseClient descendant for HTTP requests
+ """
+ super(BaseManager, self).__init__()
+ self.client = client
+
+ def _list(self, url, response_key, obj_class=None, json=None):
+ """List the collection.
+
+ :param url: a partial URL, e.g., '/servers'
+ :param response_key: the key to be looked up in response dictionary,
+ e.g., 'servers'
+ :param obj_class: class for constructing the returned objects
+ (self.resource_class will be used by default)
+ :param json: data that will be encoded as JSON and passed in POST
+ request (GET will be sent by default)
+ """
+ if json:
+ body = self.client.post(url, json=json).json()
+ else:
+ body = self.client.get(url).json()
+
+ if obj_class is None:
+ obj_class = self.resource_class
+
+ data = body[response_key]
+ # NOTE(ja): keystone returns values as list as {'values': [ ... ]}
+ # unlike other services which just return the list...
+ try:
+ data = data['values']
+ except (KeyError, TypeError):
+ pass
+
+ return [obj_class(self, res, loaded=True) for res in data if res]
+
+ def _get(self, url, response_key):
+ """Get an object from collection.
+
+ :param url: a partial URL, e.g., '/servers'
+ :param response_key: the key to be looked up in response dictionary,
+ e.g., 'server'
+ """
+ body = self.client.get(url).json()
+ return self.resource_class(self, body[response_key], loaded=True)
+
+ def _head(self, url):
+ """Retrieve request headers for an object.
+
+ :param url: a partial URL, e.g., '/servers'
+ """
+ resp = self.client.head(url)
+ return resp.status_code == 204
+
+ def _post(self, url, json, response_key, return_raw=False):
+ """Create an object.
+
+ :param url: a partial URL, e.g., '/servers'
+ :param json: data that will be encoded as JSON and passed in POST
+ request (GET will be sent by default)
+ :param response_key: the key to be looked up in response dictionary,
+ e.g., 'servers'
+ :param return_raw: flag to force returning raw JSON instead of
+ Python object of self.resource_class
+ """
+ body = self.client.post(url, json=json).json()
+ if return_raw:
+ return body[response_key]
+ return self.resource_class(self, body[response_key])
+
+ def _put(self, url, json=None, response_key=None):
+ """Update an object with PUT method.
+
+ :param url: a partial URL, e.g., '/servers'
+ :param json: data that will be encoded as JSON and passed in POST
+ request (GET will be sent by default)
+ :param response_key: the key to be looked up in response dictionary,
+ e.g., 'servers'
+ """
+ resp = self.client.put(url, json=json)
+ # PUT requests may not return a body
+ if resp.content:
+ body = resp.json()
+ if response_key is not None:
+ return self.resource_class(self, body[response_key])
+ else:
+ return self.resource_class(self, body)
+
+ def _patch(self, url, json=None, response_key=None):
+ """Update an object with PATCH method.
+
+ :param url: a partial URL, e.g., '/servers'
+ :param json: data that will be encoded as JSON and passed in POST
+ request (GET will be sent by default)
+ :param response_key: the key to be looked up in response dictionary,
+ e.g., 'servers'
+ """
+ body = self.client.patch(url, json=json).json()
+ if response_key is not None:
+ return self.resource_class(self, body[response_key])
+ else:
+ return self.resource_class(self, body)
+
+ def _delete(self, url):
+ """Delete an object.
+
+ :param url: a partial URL, e.g., '/servers/my-server'
+ """
+ return self.client.delete(url)
+
+
+class ManagerWithFind(BaseManager):
+ """Manager with additional `find()`/`findall()` methods."""
+
+ __metaclass__ = abc.ABCMeta
+
+ @abc.abstractmethod
+ def list(self):
+ pass
+
+ def find(self, **kwargs):
+ """Find a single item with attributes matching ``**kwargs``.
+
+ This isn't very efficient: it loads the entire list then filters on
+ the Python side.
+ """
+ matches = self.findall(**kwargs)
+ num_matches = len(matches)
+ if num_matches == 0:
+ msg = "No %s matching %s." % (self.resource_class.__name__, kwargs)
+ raise exceptions.NotFound(msg)
+ elif num_matches > 1:
+ raise exceptions.NoUniqueMatch()
+ else:
+ return matches[0]
+
+ def findall(self, **kwargs):
+ """Find all items with attributes matching ``**kwargs``.
+
+ This isn't very efficient: it loads the entire list then filters on
+ the Python side.
+ """
+ found = []
+ searches = kwargs.items()
+
+ for obj in self.list():
+ try:
+ if all(getattr(obj, attr) == value
+ for (attr, value) in searches):
+ found.append(obj)
+ except AttributeError:
+ continue
+
+ return found
+
+
+class CrudManager(BaseManager):
+ """Base manager class for manipulating entities.
+
+ Children of this class are expected to define a `collection_key` and `key`.
+
+ - `collection_key`: Usually a plural noun by convention (e.g. `entities`);
+ used to refer collections in both URL's (e.g. `/v3/entities`) and JSON
+ objects containing a list of member resources (e.g. `{'entities': [{},
+ {}, {}]}`).
+ - `key`: Usually a singular noun by convention (e.g. `entity`); used to
+ refer to an individual member of the collection.
+
+ """
+ collection_key = None
+ key = None
+
+ def build_url(self, base_url=None, **kwargs):
+ """Builds a resource URL for the given kwargs.
+
+ Given an example collection where `collection_key = 'entities'` and
+ `key = 'entity'`, the following URL's could be generated.
+
+ By default, the URL will represent a collection of entities, e.g.::
+
+ /entities
+
+ If kwargs contains an `entity_id`, then the URL will represent a
+ specific member, e.g.::
+
+ /entities/{entity_id}
+
+ :param base_url: if provided, the generated URL will be appended to it
+ """
+ url = base_url if base_url is not None else ''
+
+ url += '/%s' % self.collection_key
+
+ # do we have a specific entity?
+ entity_id = kwargs.get('%s_id' % self.key)
+ if entity_id is not None:
+ url += '/%s' % entity_id
+
+ return url
+
+ def _filter_kwargs(self, kwargs):
+ """Drop null values and handle ids."""
+ for key, ref in kwargs.copy().iteritems():
+ if ref is None:
+ kwargs.pop(key)
+ else:
+ if isinstance(ref, Resource):
+ kwargs.pop(key)
+ kwargs['%s_id' % key] = getid(ref)
+ return kwargs
+
+ def create(self, **kwargs):
+ kwargs = self._filter_kwargs(kwargs)
+ return self._post(
+ self.build_url(**kwargs),
+ {self.key: kwargs},
+ self.key)
+
+ def get(self, **kwargs):
+ kwargs = self._filter_kwargs(kwargs)
+ return self._get(
+ self.build_url(**kwargs),
+ self.key)
+
+ def head(self, **kwargs):
+ kwargs = self._filter_kwargs(kwargs)
+ return self._head(self.build_url(**kwargs))
+
+ def list(self, base_url=None, **kwargs):
+ """List the collection.
+
+ :param base_url: if provided, the generated URL will be appended to it
+ """
+ kwargs = self._filter_kwargs(kwargs)
+
+ return self._list(
+ '%(base_url)s%(query)s' % {
+ 'base_url': self.build_url(base_url=base_url, **kwargs),
+ 'query': '?%s' % urllib.urlencode(kwargs) if kwargs else '',
+ },
+ self.collection_key)
+
+ def put(self, base_url=None, **kwargs):
+ """Update an element.
+
+ :param base_url: if provided, the generated URL will be appended to it
+ """
+ kwargs = self._filter_kwargs(kwargs)
+
+ return self._put(self.build_url(base_url=base_url, **kwargs))
+
+ def update(self, **kwargs):
+ kwargs = self._filter_kwargs(kwargs)
+ params = kwargs.copy()
+ params.pop('%s_id' % self.key)
+
+ return self._patch(
+ self.build_url(**kwargs),
+ {self.key: params},
+ self.key)
+
+ def delete(self, **kwargs):
+ kwargs = self._filter_kwargs(kwargs)
+
+ return self._delete(
+ self.build_url(**kwargs))
+
+ def find(self, base_url=None, **kwargs):
+ """Find a single item with attributes matching ``**kwargs``.
+
+ :param base_url: if provided, the generated URL will be appended to it
+ """
+ kwargs = self._filter_kwargs(kwargs)
+
+ rl = self._list(
+ '%(base_url)s%(query)s' % {
+ 'base_url': self.build_url(base_url=base_url, **kwargs),
+ 'query': '?%s' % urllib.urlencode(kwargs) if kwargs else '',
+ },
+ self.collection_key)
+ num = len(rl)
+
+ if num == 0:
+ msg = "No %s matching %s." % (self.resource_class.__name__, kwargs)
+ raise exceptions.NotFound(404, msg)
+ elif num > 1:
+ raise exceptions.NoUniqueMatch
+ else:
+ return rl[0]
+
+
+class Extension(HookableMixin):
+ """Extension descriptor."""
+
+ SUPPORTED_HOOKS = ('__pre_parse_args__', '__post_parse_args__')
+ manager_class = None
+
+ def __init__(self, name, module):
+ super(Extension, self).__init__()
+ self.name = name
+ self.module = module
+ self._parse_extension_module()
+
+ def _parse_extension_module(self):
+ self.manager_class = None
+ for attr_name, attr_value in self.module.__dict__.items():
+ if attr_name in self.SUPPORTED_HOOKS:
+ self.add_hook(attr_name, attr_value)
+ else:
+ try:
+ if issubclass(attr_value, BaseManager):
+ self.manager_class = attr_value
+ except TypeError:
+ pass
+
+ def __repr__(self):
+ return "<Extension '%s'>" % self.name
+
+
+class Resource(object):
+ """Base class for OpenStack resources (tenant, user, etc.).
+
+ This is pretty much just a bag for attributes.
+ """
+
+ HUMAN_ID = False
+ NAME_ATTR = 'name'
+
+ def __init__(self, manager, info, loaded=False):
+ """Populate and bind to a manager.
+
+ :param manager: BaseManager object
+ :param info: dictionary representing resource attributes
+ :param loaded: prevent lazy-loading if set to True
+ """
+ self.manager = manager
+ self._info = info
+ self._add_details(info)
+ self._loaded = loaded
+
+ def __repr__(self):
+ reprkeys = sorted(k
+ for k in self.__dict__.keys()
+ if k[0] != '_' and k != 'manager')
+ info = ", ".join("%s=%s" % (k, getattr(self, k)) for k in reprkeys)
+ return "<%s %s>" % (self.__class__.__name__, info)
+
+ @property
+ def human_id(self):
+ """Human-readable ID which can be used for bash completion.
+ """
+ if self.NAME_ATTR in self.__dict__ and self.HUMAN_ID:
+ return strutils.to_slug(getattr(self, self.NAME_ATTR))
+ return None
+
+ def _add_details(self, info):
+ for (k, v) in info.iteritems():
+ try:
+ setattr(self, k, v)
+ self._info[k] = v
+ except AttributeError:
+ # In this case we already defined the attribute on the class
+ pass
+
+ def __getattr__(self, k):
+ if k not in self.__dict__:
+ #NOTE(bcwaldon): disallow lazy-loading if already loaded once
+ if not self.is_loaded():
+ self.get()
+ return self.__getattr__(k)
+
+ raise AttributeError(k)
+ else:
+ return self.__dict__[k]
+
+ def get(self):
+ # set_loaded() first ... so if we have to bail, we know we tried.
+ self.set_loaded(True)
+ if not hasattr(self.manager, 'get'):
+ return
+
+ new = self.manager.get(self.id)
+ if new:
+ self._add_details(new._info)
+
+ def __eq__(self, other):
+ if not isinstance(other, Resource):
+ return NotImplemented
+ # two resources of different types are not equal
+ if not isinstance(other, self.__class__):
+ return False
+ if hasattr(self, 'id') and hasattr(other, 'id'):
+ return self.id == other.id
+ return self._info == other._info
+
+ def is_loaded(self):
+ return self._loaded
+
+ def set_loaded(self, val):
+ self._loaded = val
diff --git a/openstack/common/apiclient/client.py b/openstack/common/apiclient/client.py
new file mode 100644
index 0000000..cb3e1d7
--- /dev/null
+++ b/openstack/common/apiclient/client.py
@@ -0,0 +1,360 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2010 Jacob Kaplan-Moss
+# Copyright 2011 OpenStack LLC
+# Copyright 2011 Piston Cloud Computing, Inc.
+# Copyright 2013 Alessio Ababilov
+# Copyright 2013 Grid Dynamics
+# Copyright 2013 OpenStack Foundation
+# 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.
+
+"""
+OpenStack Client interface. Handles the REST calls and responses.
+"""
+
+# E0202: An attribute inherited from %s hide this method
+# pylint: disable=E0202
+
+import logging
+import time
+
+try:
+ import simplejson as json
+except ImportError:
+ import json
+
+import requests
+
+from openstack.common.apiclient import exceptions
+from openstack.common import importutils
+
+
+_logger = logging.getLogger(__name__)
+
+
+class HTTPClient(object):
+ """This client handles sending HTTP requests to OpenStack servers.
+
+ Features:
+ - share authentication information between several clients to different
+ services (e.g., for compute and image clients);
+ - reissue authentication request for expired tokens;
+ - encode/decode JSON bodies;
+ - raise exeptions on HTTP errors;
+ - pluggable authentication;
+ - store authentication information in a keyring;
+ - store time spent for requests;
+ - register clients for particular services, so one can use
+ `http_client.identity` or `http_client.compute`;
+ - log requests and responses in a format that is easy to copy-and-paste
+ into terminal and send the same request with curl.
+ """
+
+ user_agent = "openstack.common.apiclient"
+
+ def __init__(self,
+ auth_plugin,
+ region_name=None,
+ endpoint_type="publicURL",
+ original_ip=None,
+ verify=True,
+ cert=None,
+ timeout=None,
+ timings=False,
+ keyring_saver=None,
+ debug=False,
+ user_agent=None,
+ http=None):
+ self.auth_plugin = auth_plugin
+
+ self.endpoint_type = endpoint_type
+ self.region_name = region_name
+
+ self.original_ip = original_ip
+ self.timeout = timeout
+ self.verify = verify
+ self.cert = cert
+
+ self.keyring_saver = keyring_saver
+ self.debug = debug
+ self.user_agent = user_agent or self.user_agent
+
+ self.times = [] # [("item", starttime, endtime), ...]
+ self.timings = timings
+
+ # requests within the same session can reuse TCP connections from pool
+ self.http = http or requests.Session()
+
+ self.cached_token = None
+
+ def _http_log_req(self, method, url, kwargs):
+ if not self.debug:
+ return
+
+ string_parts = [
+ "curl -i",
+ "-X '%s'" % method,
+ "'%s'" % url,
+ ]
+
+ for element in kwargs['headers']:
+ header = "-H '%s: %s'" % (element, kwargs['headers'][element])
+ string_parts.append(header)
+
+ _logger.debug("REQ: %s" % " ".join(string_parts))
+ if 'data' in kwargs:
+ _logger.debug("REQ BODY: %s\n" % (kwargs['data']))
+
+ def _http_log_resp(self, resp):
+ if not self.debug:
+ return
+ _logger.debug(
+ "RESP: [%s] %s\n",
+ resp.status_code,
+ resp.headers)
+ if resp._content_consumed:
+ _logger.debug(
+ "RESP BODY: %s\n",
+ resp.text)
+
+ def serialize(self, kwargs):
+ if kwargs.get('json') is not None:
+ kwargs['headers']['Content-Type'] = 'application/json'
+ kwargs['data'] = json.dumps(kwargs['json'])
+ try:
+ del kwargs['json']
+ except KeyError:
+ pass
+
+ def get_timings(self):
+ return self.times
+
+ def reset_timings(self):
+ self.times = []
+
+ def request(self, method, url, **kwargs):
+ """Send an http request with the specified characteristics.
+
+ Wrapper around `requests.Session.request` to handle tasks such as
+ setting headers, JSON encoding/decoding, and error handling.
+
+ :param method: method of HTTP request
+ :param url: URL of HTTP request
+ :param kwargs: any other parameter that can be passed to
+' requests.Session.request (such as `headers`) or `json`
+ that will be encoded as JSON and used as `data` argument
+ """
+ kwargs.setdefault("headers", kwargs.get("headers", {}))
+ kwargs["headers"]["User-Agent"] = self.user_agent
+ if self.original_ip:
+ kwargs["headers"]["Forwarded"] = "for=%s;by=%s" % (
+ self.original_ip, self.user_agent)
+ if self.timeout is not None:
+ kwargs.setdefault("timeout", self.timeout)
+ kwargs.setdefault("verify", self.verify)
+ if self.cert is not None:
+ kwargs.setdefault("cert", self.cert)
+ self.serialize(kwargs)
+
+ self._http_log_req(method, url, kwargs)
+ if self.timings:
+ start_time = time.time()
+ resp = self.http.request(method, url, **kwargs)
+ if self.timings:
+ self.times.append(("%s %s" % (method, url),
+ start_time, time.time()))
+ self._http_log_resp(resp)
+
+ if resp.status_code >= 400:
+ _logger.debug(
+ "Request returned failure status: %s",
+ resp.status_code)
+ raise exceptions.from_response(resp, method, url)
+
+ return resp
+
+ @staticmethod
+ def concat_url(endpoint, url):
+ """Concatenate endpoint and final URL.
+
+ E.g., "http://keystone/v2.0/" and "/tokens" are concatenated to
+ "http://keystone/v2.0/tokens".
+
+ :param endpoint: the base URL
+ :param url: the final URL
+ """
+ return "%s/%s" % (endpoint.rstrip("/"), url.strip("/"))
+
+ def client_request(self, client, method, url, **kwargs):
+ """Send an http request using `client`'s endpoint and specified `url`.
+
+ If request was rejected as unauthorized (possibly because the token is
+ expired), issue one authorization attempt and send the request once
+ again.
+
+ :param client: instance of BaseClient descendant
+ :param method: method of HTTP request
+ :param url: URL of HTTP request
+ :param kwargs: any other parameter that can be passed to
+' `HTTPClient.request`
+ """
+
+ filter_args = {
+ "endpoint_type": client.endpoint_type or self.endpoint_type,
+ "service_type": client.service_type,
+ }
+ token, endpoint = (self.cached_token, client.cached_endpoint)
+ just_authenticated = False
+ if not (token and endpoint):
+ try:
+ token, endpoint = self.auth_plugin.token_and_endpoint(
+ **filter_args)
+ except exceptions.EndpointException:
+ pass
+ if not (token and endpoint):
+ self.authenticate()
+ just_authenticated = True
+ token, endpoint = self.auth_plugin.token_and_endpoint(
+ **filter_args)
+ if not (token and endpoint):
+ raise exceptions.AuthorizationFailure(
+ "Cannot find endpoint or token for request")
+
+ old_token_endpoint = (token, endpoint)
+ kwargs.setdefault("headers", {})["X-Auth-Token"] = token
+ self.cached_token = token
+ client.cached_endpoint = endpoint
+ # Perform the request once. If we get Unauthorized, then it
+ # might be because the auth token expired, so try to
+ # re-authenticate and try again. If it still fails, bail.
+ try:
+ return self.request(
+ method, self.concat_url(endpoint, url), **kwargs)
+ except exceptions.Unauthorized as unauth_ex:
+ if just_authenticated:
+ raise
+ self.cached_token = None
+ client.cached_endpoint = None
+ self.authenticate()
+ try:
+ token, endpoint = self.auth_plugin.token_and_endpoint(
+ **filter_args)
+ except exceptions.EndpointException:
+ raise unauth_ex
+ if (not (token and endpoint) or
+ old_token_endpoint == (token, endpoint)):
+ raise unauth_ex
+ self.cached_token = token
+ client.cached_endpoint = endpoint
+ kwargs["headers"]["X-Auth-Token"] = token
+ return self.request(
+ method, self.concat_url(endpoint, url), **kwargs)
+
+ def add_client(self, base_client_instance):
+ """Add a new instance of :class:`BaseClient` descendant.
+
+ `self` will store a reference to `base_client_instance`.
+
+ Example:
+
+ >>> def test_clients():
+ ... from keystoneclient.auth import keystone
+ ... from openstack.common.apiclient import client
+ ... auth = keystone.KeystoneAuthPlugin(
+ ... username="user", password="pass", tenant_name="tenant",
+ ... auth_url="http://auth:5000/v2.0")
+ ... openstack_client = client.HTTPClient(auth)
+ ... # create nova client
+ ... from novaclient.v1_1 import client
+ ... client.Client(openstack_client)
+ ... # create keystone client
+ ... from keystoneclient.v2_0 import client
+ ... client.Client(openstack_client)
+ ... # use them
+ ... openstack_client.identity.tenants.list()
+ ... openstack_client.compute.servers.list()
+ """
+ service_type = base_client_instance.service_type
+ if service_type and not hasattr(self, service_type):
+ setattr(self, service_type, base_client_instance)
+
+ def authenticate(self):
+ self.auth_plugin.authenticate(self)
+ # Store the authentication results in the keyring for later requests
+ if self.keyring_saver:
+ self.keyring_saver.save(self)
+
+
+class BaseClient(object):
+ """Top-level object to access the OpenStack API.
+
+ This client uses :class:`HTTPClient` to send requests. :class:`HTTPClient`
+ will handle a bunch of issues such as authentication.
+ """
+
+ service_type = None
+ endpoint_type = None # "publicURL" will be used
+ cached_endpoint = None
+
+ def __init__(self, http_client, extensions=None):
+ self.http_client = http_client
+ http_client.add_client(self)
+
+ # Add in any extensions...
+ if extensions:
+ for extension in extensions:
+ if extension.manager_class:
+ setattr(self, extension.name,
+ extension.manager_class(self))
+
+ def client_request(self, method, url, **kwargs):
+ return self.http_client.client_request(
+ self, method, url, **kwargs)
+
+ def head(self, url, **kwargs):
+ return self.client_request("HEAD", url, **kwargs)
+
+ def get(self, url, **kwargs):
+ return self.client_request("GET", url, **kwargs)
+
+ def post(self, url, **kwargs):
+ return self.client_request("POST", url, **kwargs)
+
+ def put(self, url, **kwargs):
+ return self.client_request("PUT", url, **kwargs)
+
+ def delete(self, url, **kwargs):
+ return self.client_request("DELETE", url, **kwargs)
+
+ def patch(self, url, **kwargs):
+ return self.client_request("PATCH", url, **kwargs)
+
+ @staticmethod
+ def get_class(api_name, version, version_map):
+ """Returns the client class for the requested API version
+
+ :param api_name: the name of the API, e.g. 'compute', 'image', etc
+ :param version: the requested API version
+ :param version_map: a dict of client classes keyed by version
+ :rtype: a client class for the requested API version
+ """
+ try:
+ client_path = version_map[str(version)]
+ except (KeyError, ValueError):
+ msg = "Invalid %s client version '%s'. must be one of: %s" % (
+ (api_name, version, ', '.join(version_map.keys())))
+ raise exceptions.UnsupportedVersion(msg)
+
+ return importutils.import_class(client_path)
diff --git a/openstack/common/apiclient/exceptions.py b/openstack/common/apiclient/exceptions.py
index e70d37a..b03def7 100644
--- a/openstack/common/apiclient/exceptions.py
+++ b/openstack/common/apiclient/exceptions.py
@@ -121,7 +121,7 @@ class HttpError(ClientException):
super(HttpError, self).__init__(formatted_string)
-class HttpClientError(HttpError):
+class HTTPClientError(HttpError):
"""Client-side HTTP error.
Exception for cases in which the client seems to have erred.
@@ -138,7 +138,7 @@ class HttpServerError(HttpError):
message = "HTTP Server Error"
-class BadRequest(HttpClientError):
+class BadRequest(HTTPClientError):
"""HTTP 400 - Bad Request.
The request cannot be fulfilled due to bad syntax.
@@ -147,7 +147,7 @@ class BadRequest(HttpClientError):
message = "Bad Request"
-class Unauthorized(HttpClientError):
+class Unauthorized(HTTPClientError):
"""HTTP 401 - Unauthorized.
Similar to 403 Forbidden, but specifically for use when authentication
@@ -157,7 +157,7 @@ class Unauthorized(HttpClientError):
message = "Unauthorized"
-class PaymentRequired(HttpClientError):
+class PaymentRequired(HTTPClientError):
"""HTTP 402 - Payment Required.
Reserved for future use.
@@ -166,7 +166,7 @@ class PaymentRequired(HttpClientError):
message = "Payment Required"
-class Forbidden(HttpClientError):
+class Forbidden(HTTPClientError):
"""HTTP 403 - Forbidden.
The request was a valid request, but the server is refusing to respond
@@ -176,7 +176,7 @@ class Forbidden(HttpClientError):
message = "Forbidden"
-class NotFound(HttpClientError):
+class NotFound(HTTPClientError):
"""HTTP 404 - Not Found.
The requested resource could not be found but may be available again
@@ -186,7 +186,7 @@ class NotFound(HttpClientError):
message = "Not Found"
-class MethodNotAllowed(HttpClientError):
+class MethodNotAllowed(HTTPClientError):
"""HTTP 405 - Method Not Allowed.
A request was made of a resource using a request method not supported
@@ -196,7 +196,7 @@ class MethodNotAllowed(HttpClientError):
message = "Method Not Allowed"
-class NotAcceptable(HttpClientError):
+class NotAcceptable(HTTPClientError):
"""HTTP 406 - Not Acceptable.
The requested resource is only capable of generating content not
@@ -206,7 +206,7 @@ class NotAcceptable(HttpClientError):
message = "Not Acceptable"
-class ProxyAuthenticationRequired(HttpClientError):
+class ProxyAuthenticationRequired(HTTPClientError):
"""HTTP 407 - Proxy Authentication Required.
The client must first authenticate itself with the proxy.
@@ -215,7 +215,7 @@ class ProxyAuthenticationRequired(HttpClientError):
message = "Proxy Authentication Required"
-class RequestTimeout(HttpClientError):
+class RequestTimeout(HTTPClientError):
"""HTTP 408 - Request Timeout.
The server timed out waiting for the request.
@@ -224,7 +224,7 @@ class RequestTimeout(HttpClientError):
message = "Request Timeout"
-class Conflict(HttpClientError):
+class Conflict(HTTPClientError):
"""HTTP 409 - Conflict.
Indicates that the request could not be processed because of conflict
@@ -234,7 +234,7 @@ class Conflict(HttpClientError):
message = "Conflict"
-class Gone(HttpClientError):
+class Gone(HTTPClientError):
"""HTTP 410 - Gone.
Indicates that the resource requested is no longer available and will
@@ -244,7 +244,7 @@ class Gone(HttpClientError):
message = "Gone"
-class LengthRequired(HttpClientError):
+class LengthRequired(HTTPClientError):
"""HTTP 411 - Length Required.
The request did not specify the length of its content, which is
@@ -254,7 +254,7 @@ class LengthRequired(HttpClientError):
message = "Length Required"
-class PreconditionFailed(HttpClientError):
+class PreconditionFailed(HTTPClientError):
"""HTTP 412 - Precondition Failed.
The server does not meet one of the preconditions that the requester
@@ -264,7 +264,7 @@ class PreconditionFailed(HttpClientError):
message = "Precondition Failed"
-class RequestEntityTooLarge(HttpClientError):
+class RequestEntityTooLarge(HTTPClientError):
"""HTTP 413 - Request Entity Too Large.
The request is larger than the server is willing or able to process.
@@ -281,7 +281,7 @@ class RequestEntityTooLarge(HttpClientError):
super(RequestEntityTooLarge, self).__init__(*args, **kwargs)
-class RequestUriTooLong(HttpClientError):
+class RequestUriTooLong(HTTPClientError):
"""HTTP 414 - Request-URI Too Long.
The URI provided was too long for the server to process.
@@ -290,7 +290,7 @@ class RequestUriTooLong(HttpClientError):
message = "Request-URI Too Long"
-class UnsupportedMediaType(HttpClientError):
+class UnsupportedMediaType(HTTPClientError):
"""HTTP 415 - Unsupported Media Type.
The request entity has a media type which the server or resource does
@@ -300,7 +300,7 @@ class UnsupportedMediaType(HttpClientError):
message = "Unsupported Media Type"
-class RequestedRangeNotSatisfiable(HttpClientError):
+class RequestedRangeNotSatisfiable(HTTPClientError):
"""HTTP 416 - Requested Range Not Satisfiable.
The client has asked for a portion of the file, but the server cannot
@@ -310,7 +310,7 @@ class RequestedRangeNotSatisfiable(HttpClientError):
message = "Requested Range Not Satisfiable"
-class ExpectationFailed(HttpClientError):
+class ExpectationFailed(HTTPClientError):
"""HTTP 417 - Expectation Failed.
The server cannot meet the requirements of the Expect request-header field.
@@ -319,7 +319,7 @@ class ExpectationFailed(HttpClientError):
message = "Expectation Failed"
-class UnprocessableEntity(HttpClientError):
+class UnprocessableEntity(HTTPClientError):
"""HTTP 422 - Unprocessable Entity.
The request was well-formed but was unable to be followed due to semantic
@@ -440,7 +440,7 @@ def from_response(response, method, url):
if 500 <= response.status_code < 600:
cls = HttpServerError
elif 400 <= response.status_code < 500:
- cls = HttpClientError
+ cls = HTTPClientError
else:
cls = HttpError
return cls(**kwargs)
diff --git a/openstack/common/apiclient/fake_client.py b/openstack/common/apiclient/fake_client.py
new file mode 100644
index 0000000..da125e2
--- /dev/null
+++ b/openstack/common/apiclient/fake_client.py
@@ -0,0 +1,172 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2013 OpenStack Foundation
+# 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.
+
+"""
+A fake server that "responds" to API methods with pre-canned responses.
+
+All of these responses come from the spec, so if for some reason the spec's
+wrong the tests might raise AssertionError. I've indicated in comments the
+places where actual behavior differs from the spec.
+"""
+
+# W0102: Dangerous default value %s as argument
+# pylint: disable=W0102
+
+import json
+import urlparse
+
+import requests
+
+from openstack.common.apiclient import client
+
+
+def assert_has_keys(dct, required=[], optional=[]):
+ for k in required:
+ try:
+ assert k in dct
+ except AssertionError:
+ extra_keys = set(dct.keys()).difference(set(required + optional))
+ raise AssertionError("found unexpected keys: %s" %
+ list(extra_keys))
+
+
+class TestResponse(requests.Response):
+ """Wrap requests.Response and provide a convenient initialization.
+ """
+
+ def __init__(self, data):
+ super(TestResponse, self).__init__()
+ self._content_consumed = True
+ if isinstance(data, dict):
+ self.status_code = data.get('status_code', 200)
+ # Fake the text attribute to streamline Response creation
+ text = data.get('text', "")
+ if isinstance(text, (dict, list)):
+ self._content = json.dumps(text)
+ default_headers = {
+ "Content-Type": "application/json",
+ }
+ else:
+ self._content = text
+ default_headers = {}
+ self.headers = data.get('headers') or default_headers
+ else:
+ self.status_code = data
+
+ def __eq__(self, other):
+ return (self.status_code == other.status_code and
+ self.headers == other.headers and
+ self._content == other._content)
+
+
+class FakeHTTPClient(client.HTTPClient):
+
+ def __init__(self, *args, **kwargs):
+ self.callstack = []
+ self.fixtures = kwargs.pop("fixtures", None) or {}
+ if not args and not "auth_plugin" in kwargs:
+ args = (None, )
+ super(FakeHTTPClient, self).__init__(*args, **kwargs)
+
+ def assert_called(self, method, url, body=None, pos=-1):
+ """Assert than an API method was just called.
+ """
+ expected = (method, url)
+ called = self.callstack[pos][0:2]
+ assert self.callstack, \
+ "Expected %s %s but no calls were made." % expected
+
+ assert expected == called, 'Expected %s %s; got %s %s' % \
+ (expected + called)
+
+ if body is not None:
+ if self.callstack[pos][3] != body:
+ raise AssertionError('%r != %r' %
+ (self.callstack[pos][3], body))
+
+ def assert_called_anytime(self, method, url, body=None):
+ """Assert than an API method was called anytime in the test.
+ """
+ expected = (method, url)
+
+ assert self.callstack, \
+ "Expected %s %s but no calls were made." % expected
+
+ found = False
+ entry = None
+ for entry in self.callstack:
+ if expected == entry[0:2]:
+ found = True
+ break
+
+ assert found, 'Expected %s %s; got %s' % \
+ (method, url, self.callstack)
+ if body is not None:
+ assert entry[3] == body, "%s != %s" % (entry[3], body)
+
+ self.callstack = []
+
+ def clear_callstack(self):
+ self.callstack = []
+
+ def authenticate(self):
+ pass
+
+ def client_request(self, client, method, url, **kwargs):
+ # Check that certain things are called correctly
+ if method in ["GET", "DELETE"]:
+ assert "json" not in kwargs
+
+ # Note the call
+ self.callstack.append(
+ (method,
+ url,
+ kwargs.get("headers") or {},
+ kwargs.get("json") or kwargs.get("data")))
+ try:
+ fixture = self.fixtures[url][method]
+ except KeyError:
+ pass
+ else:
+ return TestResponse({"headers": fixture[0],
+ "text": fixture[1]})
+
+ # Call the method
+ args = urlparse.parse_qsl(urlparse.urlparse(url)[4])
+ kwargs.update(args)
+ munged_url = url.rsplit('?', 1)[0]
+ munged_url = munged_url.strip('/').replace('/', '_').replace('.', '_')
+ munged_url = munged_url.replace('-', '_')
+
+ callback = "%s_%s" % (method.lower(), munged_url)
+
+ if not hasattr(self, callback):
+ raise AssertionError('Called unknown API method: %s %s, '
+ 'expected fakes method name: %s' %
+ (method, url, callback))
+
+ resp = getattr(self, callback)(**kwargs)
+ if len(resp) == 3:
+ status, headers, body = resp
+ else:
+ status, body = resp
+ headers = {}
+ return TestResponse({
+ "status_code": status,
+ "text": body,
+ "headers": headers,
+ })