diff --git a/cratonclient/base.py b/cratonclient/base.py new file mode 100644 index 0000000..d028d97 --- /dev/null +++ b/cratonclient/base.py @@ -0,0 +1,536 @@ +# Copyright 2010 Jacob Kaplan-Moss +# Copyright 2011 OpenStack Foundation +# 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.""" + +import abc +import copy +import functools +import warnings + +from oslo_utils import strutils +import six +from six.moves import urllib + +from keystoneclient import auth +from keystoneclient import exceptions +from keystoneclient.i18n import _ + + +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: # nosec(cjschaef): 'obj' doesn't contain attribute + # 'uuid', return attribute 'id' or the 'obj' + pass + try: + return obj.id + except AttributeError: + return obj + + +def filter_none(**kwargs): + """Remove any entries from a dictionary where the value is None.""" + return dict((k, v) for k, v in six.iteritems(kwargs) if v is not None) + + +def filter_kwargs(f): + @functools.wraps(f) + def func(*args, **kwargs): + new_kwargs = {} + for key, ref in six.iteritems(kwargs): + if ref is None: + # drop null values + continue + + id_value = getid(ref) + if id_value != ref: + # If an object with an id was passed, then use the id, e.g.: + # user: user(id=1) becomes user_id: 1 + key = '%s_id' % key + + new_kwargs[key] = id_value + + return f(*args, **new_kwargs) + return func + + +class Manager(object): + """Basic manager type providing common operations. + + Managers interact with a particular type of API (servers, flavors, images, + etc.) and provide CRUD operations for them. + + :param client: instance of BaseClient descendant for HTTP requests + + """ + + resource_class = None + + def __init__(self, client): + super(Manager, self).__init__() + self.client = client + + @property + def api(self): + """The client. + + .. warning:: + + This property is deprecated as of the 1.7.0 release in favor of + :meth:`client` and may be removed in the 2.0.0 release. + + """ + warnings.warn( + 'api is deprecated as of the 1.7.0 release in favor of client and ' + 'may be removed in the 2.0.0 release', DeprecationWarning) + return self.client + + def _list(self, url, response_key, obj_class=None, body=None, **kwargs): + """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 body: data that will be encoded as JSON and passed in POST + request (GET will be sent by default) + :param kwargs: Additional arguments will be passed to the request. + """ + if body: + resp, body = self.client.post(url, body=body, **kwargs) + else: + resp, body = self.client.get(url, **kwargs) + + 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): # nosec(cjschaef): keystone data values + # not as expected (see comment above), assumption is that values + # are already returned in a list (so simply utilize that list) + pass + + return [obj_class(self, res, loaded=True) for res in data if res] + + def _get(self, url, response_key, **kwargs): + """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' + :param kwargs: Additional arguments will be passed to the request. + """ + resp, body = self.client.get(url, **kwargs) + return self.resource_class(self, body[response_key], loaded=True) + + def _head(self, url, **kwargs): + """Retrieve request headers for an object. + + :param url: a partial URL, e.g., '/servers' + :param kwargs: Additional arguments will be passed to the request. + """ + resp, body = self.client.head(url, **kwargs) + return resp.status_code == 204 + + def _post(self, url, body, response_key, return_raw=False, **kwargs): + """Create an object. + + :param url: a partial URL, e.g., '/servers' + :param body: 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 + :param kwargs: Additional arguments will be passed to the request. + """ + resp, body = self.client.post(url, body=body, **kwargs) + if return_raw: + return body[response_key] + return self.resource_class(self, body[response_key]) + + def _put(self, url, body=None, response_key=None, **kwargs): + """Update an object with PUT method. + + :param url: a partial URL, e.g., '/servers' + :param body: 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 kwargs: Additional arguments will be passed to the request. + """ + resp, body = self.client.put(url, body=body, **kwargs) + # PUT requests may not return a body + if body is not None: + 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, body=None, response_key=None, **kwargs): + """Update an object with PATCH method. + + :param url: a partial URL, e.g., '/servers' + :param body: 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 kwargs: Additional arguments will be passed to the request. + """ + resp, body = self.client.patch(url, body=body, **kwargs) + 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, **kwargs): + """Delete an object. + + :param url: a partial URL, e.g., '/servers/my-server' + :param kwargs: Additional arguments will be passed to the request. + """ + return self.client.delete(url, **kwargs) + + def _update(self, url, body=None, response_key=None, method="PUT", + **kwargs): + methods = {"PUT": self.client.put, + "POST": self.client.post, + "PATCH": self.client.patch} + try: + resp, body = methods[method](url, body=body, + **kwargs) + except KeyError: + raise exceptions.ClientException(_("Invalid update method: %s") + % method) + # PUT requests may not return a body + if body: + return self.resource_class(self, body[response_key]) + + +@six.add_metaclass(abc.ABCMeta) +class ManagerWithFind(Manager): + """Manager with additional `find()`/`findall()` methods.""" + + @abc.abstractmethod + def list(self): + pass # pragma: no cover + + 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. + """ + rl = self.findall(**kwargs) + num = len(rl) + + if num == 0: + msg = _("No %(name)s matching %(kwargs)s.") % { + 'name': self.resource_class.__name__, 'kwargs': kwargs} + raise exceptions.NotFound(404, msg) + elif num > 1: + raise exceptions.NoUniqueMatch + else: + return rl[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(Manager): + """Base manager class for manipulating Keystone 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 + base_url = None + + def build_url(self, dict_args_in_out=None): + """Build 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} + + If a `base_url` is provided, the generated URL will be appended to it. + + If a 'tail' is provided, it will be appended to the end of the URL. + + """ + if dict_args_in_out is None: + dict_args_in_out = {} + + url = dict_args_in_out.pop('base_url', None) or self.base_url or '' + url += '/%s' % self.collection_key + + # do we have a specific entity? + entity_id = dict_args_in_out.pop('%s_id' % self.key, None) + if entity_id is not None: + url += '/%s' % entity_id + + if dict_args_in_out.get('tail'): + url += dict_args_in_out['tail'] + + return url + + @filter_kwargs + def create(self, **kwargs): + url = self.build_url(dict_args_in_out=kwargs) + return self._post( + url, + {self.key: kwargs}, + self.key) + + @filter_kwargs + def get(self, **kwargs): + return self._get( + self.build_url(dict_args_in_out=kwargs), + self.key) + + @filter_kwargs + def head(self, **kwargs): + return self._head(self.build_url(dict_args_in_out=kwargs)) + + def _build_query(self, params): + return '?%s' % urllib.parse.urlencode(params) if params else '' + + def build_key_only_query(self, params_list): + """Build a query that does not include values, just keys. + + The Identity API has some calls that define queries without values, + this can not be accomplished by using urllib.parse.urlencode(). This + method builds a query using only the keys. + """ + return '?%s' % '&'.join(params_list) if params_list else '' + + @filter_kwargs + def list(self, fallback_to_auth=False, **kwargs): + if 'id' in kwargs.keys(): + # Ensure that users are not trying to call things like + # ``domains.list(id='default')`` when they should have used + # ``[domains.get(domain_id='default')]`` instead. Keystone supports + # ``GET /v3/domains/{domain_id}``, not ``GET + # /v3/domains?id={domain_id}``. + raise TypeError( + _("list() got an unexpected keyword argument 'id'. To " + "retrieve a single object using a globally unique " + "identifier, try using get() instead.")) + + url = self.build_url(dict_args_in_out=kwargs) + + try: + query = self._build_query(kwargs) + url_query = '%(url)s%(query)s' % {'url': url, 'query': query} + return self._list( + url_query, + self.collection_key) + except exceptions.EmptyCatalog: + if fallback_to_auth: + return self._list( + url_query, + self.collection_key, + endpoint_filter={'interface': auth.AUTH_INTERFACE}) + else: + raise + + @filter_kwargs + def put(self, **kwargs): + return self._update( + self.build_url(dict_args_in_out=kwargs), + method='PUT') + + @filter_kwargs + def update(self, **kwargs): + url = self.build_url(dict_args_in_out=kwargs) + + return self._update( + url, + {self.key: kwargs}, + self.key, + method='PATCH') + + @filter_kwargs + def delete(self, **kwargs): + return self._delete( + self.build_url(dict_args_in_out=kwargs)) + + @filter_kwargs + def find(self, **kwargs): + """Find a single item with attributes matching ``**kwargs``.""" + url = self.build_url(dict_args_in_out=kwargs) + + query = self._build_query(kwargs) + rl = self._list( + '%(url)s%(query)s' % { + 'url': url, + 'query': query, + }, + self.collection_key) + num = len(rl) + + if num == 0: + msg = _("No %(name)s matching %(kwargs)s.") % { + 'name': self.resource_class.__name__, 'kwargs': kwargs} + raise exceptions.NotFound(404, msg) + elif num > 1: + raise exceptions.NoUniqueMatch + else: + return rl[0] + + +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): + """Return string representation of resource attributes.""" + 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.HUMAN_ID: + name = getattr(self, self.NAME_ATTR, None) + if name is not None: + return strutils.to_slug(name) + return None + + def _add_details(self, info): + for (k, v) in six.iteritems(info): + try: + setattr(self, k, v) + self._info[k] = v + except AttributeError: # nosec(cjschaef): we already defined the + # attribute on the class + pass + + def __getattr__(self, k): + """Checking attrbiute existence.""" + 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): + """Support for lazy loading details. + + Some clients, such as novaclient have the option to lazy load the + details, details which can be loaded with this function. + """ + # 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) + self._add_details( + {'x_request_id': self.manager.client.last_request_id}) + + def __eq__(self, other): + """Define equality for resources.""" + if not isinstance(other, Resource): + return NotImplemented + # two resources of different types are not equal + if not isinstance(other, self.__class__): + return False + return self._info == other._info + + def is_loaded(self): + return self._loaded + + def set_loaded(self, val): + self._loaded = val + + def to_dict(self): + return copy.deepcopy(self._info) + + def delete(self): + return self.manager.delete(self) diff --git a/cratonclient/crud.py b/cratonclient/crud.py new file mode 100644 index 0000000..ef9689d --- /dev/null +++ b/cratonclient/crud.py @@ -0,0 +1,177 @@ +"""Client for CRUD operations.""" + +class CRUDClient(object): + """Class that handles the basic create, read, upload, delete workflow.""" + + key = None + base_path = None + resource_class = None + + def __init__(self, session, url): + self.session = session + self.url = url.rstrip('/') + + def build_url(self, path_arguments=None): + """Build a complete URL from the url, base_path, and arguments. + + A CRUDClient is constructed with the base URL, e.g. + + .. code-block:: python + + RegionManager(url='https://10.1.1.0:8080/v1', ...) + + The child class of the CRUDClient may set the ``base_path``, e.g., + + .. code-block:: python + + base_path = '/regions' + + And it's ``key``, e.g., + + .. code-block:: python + + key = 'region' + + And based on the ``path_arguments`` parameter we will construct a + complete URL. For example, if someone calls: + + .. code-block:: python + + self.build_url(path_arguments={'region_id': 1}) + + with the hypothetical values above, we would return + + https://10.1.1.0:8080/v1/regions/1 + + Users can also override ``base_path`` in ``path_arguments``. + """ + if path_arguments is None: + path_arguments = {} + + base_path = path_arguments.pop('base_path', None) or self.base_path + item_id = path_arguments.pop('{0}_id'.format(self.key), None) + + url = self.url + base_path + + if item_id is not None: + url += '/{0}'.format(item_id) + + return url + + def create(self, **kwargs): + """Create a new item based on the keyword arguments provided.""" + url = self.build_url(path_arguments=kwargs) + response = self.session.post(url, json=kwargs) + return self.resource_class(self, response.json()) + + def get(self, **kwargs): + """Retrieve the item based on the keyword arguments provided.""" + url = self.build_url(path_arguments=kwargs) + response = self.session.get(url) + return self.resource_class(self, response.json()) + + def list(self, **kwargs): + """List the items from this endpoint.""" + url = self.build_url(path_arguments=kwargs) + response = self.session.get(url, params=kwargs) + return [self.resource_class(self, item) for item in response.json()] + + +# NOTE(sigmavirus24): Credit for this Resource object goes to the +# keystoneclient developers and contributors. +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): + """Return string representation of resource attributes.""" + 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.HUMAN_ID: + name = getattr(self, self.NAME_ATTR, None) + if name is not None: + return strutils.to_slug(name) + return None + + def _add_details(self, info): + for (k, v) in info.items(): + try: + setattr(self, k, v) + self._info[k] = v + except AttributeError: # nosec(cjschaef): we already defined the + # attribute on the class + pass + + def __getattr__(self, k): + """Checking attrbiute existence.""" + 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): + """Support for lazy loading details. + + Some clients, such as novaclient have the option to lazy load the + details, details which can be loaded with this function. + """ + # 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) + self._add_details( + {'x_request_id': self.manager.client.last_request_id}) + + def __eq__(self, other): + """Define equality for resources.""" + if not isinstance(other, Resource): + return NotImplemented + # two resources of different types are not equal + if not isinstance(other, self.__class__): + return False + return self._info == other._info + + def is_loaded(self): + return self._loaded + + def set_loaded(self, val): + self._loaded = val + + def to_dict(self): + return copy.deepcopy(self._info) + + def delete(self): + return self.manager.delete(self) diff --git a/cratonclient/exceptions.py b/cratonclient/exceptions.py new file mode 100644 index 0000000..c314171 --- /dev/null +++ b/cratonclient/exceptions.py @@ -0,0 +1,256 @@ +# -*- coding: utf-8 -*- + +# 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. +"""Exception classes and logic for cratonclient.""" + +class ClientException(Exception): + """Base exception class for all exceptions in cratonclient.""" + + message = None + + def __init__(self, message=None): + """Initialize our exception instance with our class level message.""" + if message is None: + if self.message is None: + message = self.__class__.__name__ + else: + message = self.message + super(ClientException, self).__init__(message) + + +class Timeout(ClientException): + """Catch-all class for connect and read timeouts from requests.""" + + message = 'Request timed out' + + def __init__(self, message=None, **kwargs): + self.original_exception = kwargs.pop('exception', None) + super(Timeout, self).__init__(message) + + +class HTTPError(ClientException): + """Base exception class for all HTTP related exceptions in.""" + + message = "An error occurred while talking to the remote server." + status_code = None + + def __init__(self, message=None, **kwargs): + self.response = kwargs.pop('response', None) + self.original_exception = kwargs.pop('exception', None) + self.status_code = (self.status_code + or getattr(self.response, 'status_code', None)) + super(HTTPError, self).__init__(message) + + @property + def status_code(self): + """Shim to provide a similar API to other OpenStack clients.""" + return self.status_code + + @status_code.setter + def status_code(self, code): + self.status_code = code + + +class ConnectionFailed(HTTPError): + """Connecting to the server failed.""" + + message = "An error occurred while connecting to the server.""" + + +class HTTPClientError(HTTPError): + """Base exception for client side errors (4xx status codes).""" + + message = "Something went wrong with the request.""" + + +class BadRequest(HTTPClientError): + """Client sent a malformed request.""" + + status_code = 400 + message = "The request sent to the server was invalid." + + +class Unauthorized(HTTPClientError): + """Client is unauthorized to access the resource in question.""" + + status_code = 401 + message = ("The user has either provided insufficient parameters for " + "authentication or is not authorized to access this resource.") + + +class Forbidden(HTTPClientError): + """Client is forbidden to access the resource.""" + + status_code = 403 + message = ("The user was unable to access the resource because they are " + "forbidden.") + + +class NotFound(HTTPClientError): + """Resource could not be found.""" + + status_code = 404 + message = "The requested resource was not found.""" + + +class MethodNotAllowed(HTTPClientError): + """The request method is not supported.""" + + status_code = 405 + message = "The method used in the request is not supported." + + +class NotAcceptable(HTTPClientError): + """The requested resource can not respond with acceptable content. + + Based on the Accept headers specified by the client, the resource can not + generate content that is an acceptable content-type. + """ + + status_code = 406 + message = "The resource can not return acceptable content." + + +class ProxyAuthenticationRequired(HTTPClientError): + """The client must first authenticate itself with the proxy.""" + + status_code = 407 + message = "The client must first authenticate itself with a proxy." + + +class Conflict(HTTPClientError): + """The request presents a conflict.""" + + status_code = 409 + message = "The request could not be processed due to a conflict." + + +class Gone(HTTPClientError): + """The requested resource is no longer available. + + The resource requested is no longer available and will not be available + again. + """ + + status_code = 410 + message = ("The resource requested is no longer available and will not be" + " available again.") + + +class LengthRequired(HTTPClientError): + """The request did not specify a Content-Length header.""" + + status_code = 411 + message = ("The request did not contain a Content-Length header but one" + " was required by the resource.") + + +class PreconditionFailed(HTTPClientError): + """The server failed to meet one of the preconditions in the request.""" + + status_code = 412 + message = ("The server failed to meet one of the preconditions in the " + "request.") + + +class RequestEntityTooLarge(HTTPClientError): + """The request is larger than the server is willing or able to process.""" + + status_code = 413 + message = ("The request is larger than the server is willing or able to " + "process.") + + +class RequestUriTooLong(HTTPClientError): + """The URI provided was too long for the server to process.""" + + status_code = 414 + message = "The URI provided was too long for the server to process." + + +class UnsupportedMediaType(HTTPClientError): + """The request entity has a media type which is unsupported.""" + + status_code = 415 + message = ("The request entity has a media type which is unsupported by " + "the server or resource.") + + +class RequestedRangeNotSatisfiable(HTTPClientError): + """The requestor wanted a range but the server was unable to provide it.""" + + status_code = 416 + message = ("The requestor wanted a range but the server was unable to " + "provide it.") + + +class UnprocessableEntity(HTTPClientError): + """There were semantic errors in the request.""" + + status_code = 422 + message = ("The request is of a valid content-type and structure but " + "semantically invalid.") + + +_4xx_classes = [ + BadRequest, + Unauthorized, + Forbidden, + NotFound, + MethodNotAllowed, + NotAcceptable, + ProxyAuthenticationRequired, + Conflict, + Gone, + LengthRequired, + PreconditionFailed, + RequestEntityTooLarge, + RequestUriTooLong, + UnsupportedMediaType, + RequestedRangeNotSatisfiable, + UnprocessableEntity, +] +_4xx_codes = {cls.status_code: cls for cls in _4xx_classes} + + +class HTTPServerError(HTTPError): + """The server encountered an error it could not recover from.""" + + message = "HTTP Server-side Error" + + +class InternalServerError(HTTPServerError): + """The server encountered an error it could not recover from.""" + + status_code = 500 + message = ("There was an internal server error that could not be recovered" + " from.") + + +_5xx_classes = [ + InternalServerError, + # NOTE(sigmavirus24): Allow for future expansion +] +_5xx_codes = {cls.status_code: cls for cls in _5xx_classes} + + +def error_from(response): + """Find an error code that matches a response status_code.""" + if 400 <= response.status_code < 500: + cls = _4xx_codes.get(response.status_code, HTTPClientError) + elif 500 <= response.status_code < 600: + cls = _5xx_codes.get(response.status_code, HTTPServerError) + else: + cls = HTTPError + + return cls(response=response) diff --git a/cratonclient/session.py b/cratonclient/session.py new file mode 100644 index 0000000..3d5a88e --- /dev/null +++ b/cratonclient/session.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- + +# 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. +"""Craton-specific session details.""" +import logging + +import requests +from requests import exceptions as requests_exc + +import cratonclient +from cratonclient import exceptions as exc + +LOG = logging.getLogger(__name__) + + +class Session(object): + """Management class to allow different types of sessions to be used. + + If an instance of Craton is deployed with Keystone Middleware, this allows + for a keystoneauth session to be used so authentication will happen + immediately. + """ + + def __init__(self, session=None, username=None, token=None, project_id=None): + if session is None: + session = requests.Session() + + self._session = session + self._session.headers['X-Auth-User'] = username + self._session.headers['X-Auth-Project'] = str(project_id) + self._session.headers['X-Auth-Token'] = token + + craton_version = 'python-cratonclient/{0} '.format( + cratonclient.__version__) + old_user_agent = self._session.headers['User-Agent'] + + self._session.headers['User-Agent'] = craton_version + old_user_agent + self._session.headers['Accept'] = 'application/json' + + def delete(self, url, **kwargs): + return self.request('DELETE', url, **kwargs) + + def get(self, url, **kwargs): + return self.request('GET', url, **kwargs) + + def head(self, url, **kwargs): + return self.request('HEAD', url, **kwargs) + + def options(self, url, **kwargs): + return self.request('OPTIONS', url, **kwargs) + + def post(self, url, **kwargs): + return self.request('POST', url, **kwargs) + + def put(self, url, **kwargs): + return self.request('PUT', url, **kwargs) + + def request(self, method, url, **kwargs): + self._http_log_request(method=method, + url=url, + data=kwargs.get('data'), + headers=kwargs.get('headers', {}).copy()) + try: + response = self._session.request(method=method, + url=url, + **kwargs) + except requests_exc.HTTPError as err: + raise exc.HTTPError(exception=err, response=err.response) + # NOTE(sigmavirus24): The ordering of Timeout before ConnectionError + # is important on requests 2.x. The ConnectTimeout exception inherits + # from both ConnectionError and Timeout. To catch both connect and + # read timeouts similarly, we need to catch this one first. + except requests_exc.Timeout as err: + raise exc.Timeout(exception=err) + except requests_exc.ConnectionError: + raise exc.ConnectionFailed(exception=err) + + self._http_log_response(response) + if response.status_code >= 400: + raise exc.error_from(response) + + return response + + + def _http_log_request(self, url, method=None, data=None, + headers=None, logger=LOG): + if not logger.isEnabledFor(logging.DEBUG): + # NOTE(morganfainberg): This whole debug section is expensive, + # there is no need to do the work if we're not going to emit a + # debug log. + return + + string_parts = ['REQ: curl -g -i'] + + # NOTE(jamielennox): None means let requests do its default validation + # so we need to actually check that this is False. + if self.verify is False: + string_parts.append('--insecure') + elif isinstance(self.verify, six.string_types): + string_parts.append('--cacert "%s"' % self.verify) + + if method: + string_parts.extend(['-X', method]) + + string_parts.append(url) + + if headers: + for header in six.iteritems(headers): + string_parts.append('-H "%s: %s"' + % self._process_header(header)) + + if data: + string_parts.append("-d '%s'" % data) + try: + logger.debug(' '.join(string_parts)) + except UnicodeDecodeError: + logger.debug("Replaced characters that could not be decoded" + " in log output, original caused UnicodeDecodeError") + string_parts = [ + encodeutils.safe_decode( + part, errors='replace') for part in string_parts] + logger.debug(' '.join(string_parts)) + + def _http_log_response(self, response, logger=LOG): + if not logger.isEnabledFor(logging.DEBUG): + return + + text = _remove_service_catalog(response.text) + + string_parts = [ + 'RESP:', + '[%s]' % response.status_code + ] + for header in six.iteritems(response.headers): + string_parts.append('%s: %s' % self._process_header(header)) + if text: + string_parts.append('\nRESP BODY: %s\n' % + strutils.mask_password(text)) + + logger.debug(' '.join(string_parts)) diff --git a/cratonclient/tests/test_session.py b/cratonclient/tests/test_session.py new file mode 100644 index 0000000..74be091 --- /dev/null +++ b/cratonclient/tests/test_session.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- + +# 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. + +"""Tests for `cratonclient.session` module.""" +import uuid + +import requests + +import cratonclient +from cratonclient import session +from cratonclient.tests import base + + +USERNAME = 'example' +TOKEN = uuid.uuid4().hex +PROJECT_ID = 1 + +class TestSession(base.TestCase): + """Test our session class.""" + + def test_uses_provided_session(self): + """Verify that cratonclient does not override the session parameter.""" + requests_session = requests.Session() + craton = session.Session(session=requests_session) + self.assertIs(requests_session, craton._session) + + def test_creates_new_session(self): + """Verify that cratonclient creates a new session.""" + craton = session.Session() + self.assertIsInstance(craton._session, requests.Session) + + def test_sets_authentication_parameters_as_headers(self): + """Verify we set auth parameters as headers on the session.""" + requests_session = requests.Session() + craton = session.Session( + username=USERNAME, + token=TOKEN, + project_id=PROJECT_ID, + ) + expected_headers = { + 'X-Auth-User': USERNAME, + 'X-Auth-Token': TOKEN, + 'X-Auth-Project': str(PROJECT_ID), + 'User-Agent': 'python-cratonclient/{0} {1}'.format( + cratonclient.__version__, + requests_session.headers['User-Agent']), + 'Connection': 'keep-alive', + 'Accept-Encoding': 'gzip, deflate', + 'Accept': 'application/json', + } + self.assertItemsEqual(expected_headers, craton._session.headers) + diff --git a/cratonclient/v1/__init__.py b/cratonclient/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cratonclient/v1/client.py b/cratonclient/v1/client.py new file mode 100644 index 0000000..6a97e4c --- /dev/null +++ b/cratonclient/v1/client.py @@ -0,0 +1,10 @@ +"""Top-level client for version 1 of Craton's API.""" +from cratonclient.v1 import regions + + +class Client(object): + def __init__(self, session, url): + self._url = url + self._session = session + + self.regions = regions.RegionManager(self._session, self._url) diff --git a/cratonclient/v1/regions.py b/cratonclient/v1/regions.py new file mode 100644 index 0000000..9578c3b --- /dev/null +++ b/cratonclient/v1/regions.py @@ -0,0 +1,15 @@ +"""Regions manager code.""" +from cratonclient import crud + + +class Region(crud.Resource): + """Representation of a Region.""" + pass + + +class RegionManager(crud.CRUDClient): + """A manager for regions.""" + + key = 'region' + base_path = '/regions' + resource_class = Region diff --git a/requirements.txt b/requirements.txt index 30806d5..4bf96b9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ # process, which may cause wedges in the gate later. pbr>=1.6 +requests>=2.10.0 # Apache-2.0 diff --git a/test-requirements.txt b/test-requirements.txt index 21a7e3b..c6272e5 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -2,7 +2,8 @@ # of appearance. Changing the order has an impact on the overall integration # process, which may cause wedges in the gate later. -hacking<0.11,>=0.10.0 +hacking<0.12,>=0.10.0 +flake8_docstrings==0.2.1.post1 # MIT coverage>=3.6 python-subunit>=0.0.18