escalator should use oslo.xxx instead of oslo-xxx
[escalator.git] / client / escalatorclient / openstack / common / apiclient / base.py
1 # Copyright 2010 Jacob Kaplan-Moss
2 # Copyright 2011 OpenStack Foundation
3 # Copyright 2012 Grid Dynamics
4 # Copyright 2013 OpenStack Foundation
5 # All Rights Reserved.
6 #
7 #    Licensed under the Apache License, Version 2.0 (the "License"); you may
8 #    not use this file except in compliance with the License. You may obtain
9 #    a copy of the License at
10 #
11 #         http://www.apache.org/licenses/LICENSE-2.0
12 #
13 #    Unless required by applicable law or agreed to in writing, software
14 #    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
15 #    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
16 #    License for the specific language governing permissions and limitations
17 #    under the License.
18
19 """
20 Base utilities to build API operation managers and objects on top of.
21 """
22
23 ########################################################################
24 #
25 # THIS MODULE IS DEPRECATED
26 #
27 # Please refer to
28 # https://etherpad.openstack.org/p/kilo-escalatorclient-library-proposals for
29 # the discussion leading to this deprecation.
30 #
31 # We recommend checking out the python-openstacksdk project
32 # (https://launchpad.net/python-openstacksdk) instead.
33 #
34 ########################################################################
35
36
37 # E1102: %s is not callable
38 # pylint: disable=E1102
39
40 import abc
41 import copy
42
43 from oslo.utils import strutils
44 import six
45 from six.moves.urllib import parse
46
47 from escalatorclient.openstack.common._i18n import _
48 from escalatorclient.openstack.common.apiclient import exceptions
49
50
51 def getid(obj):
52     """Return id if argument is a Resource.
53
54     Abstracts the common pattern of allowing both an object or an object's ID
55     (UUID) as a parameter when dealing with relationships.
56     """
57     try:
58         if obj.uuid:
59             return obj.uuid
60     except AttributeError:
61         pass
62     try:
63         return obj.id
64     except AttributeError:
65         return obj
66
67
68 # TODO(aababilov): call run_hooks() in HookableMixin's child classes
69 class HookableMixin(object):
70     """Mixin so classes can register and run hooks."""
71     _hooks_map = {}
72
73     @classmethod
74     def add_hook(cls, hook_type, hook_func):
75         """Add a new hook of specified type.
76
77         :param cls: class that registers hooks
78         :param hook_type: hook type, e.g., '__pre_parse_args__'
79         :param hook_func: hook function
80         """
81         if hook_type not in cls._hooks_map:
82             cls._hooks_map[hook_type] = []
83
84         cls._hooks_map[hook_type].append(hook_func)
85
86     @classmethod
87     def run_hooks(cls, hook_type, *args, **kwargs):
88         """Run all hooks of specified type.
89
90         :param cls: class that registers hooks
91         :param hook_type: hook type, e.g., '__pre_parse_args__'
92         :param args: args to be passed to every hook function
93         :param kwargs: kwargs to be passed to every hook function
94         """
95         hook_funcs = cls._hooks_map.get(hook_type) or []
96         for hook_func in hook_funcs:
97             hook_func(*args, **kwargs)
98
99
100 class BaseManager(HookableMixin):
101     """Basic manager type providing common operations.
102
103     Managers interact with a particular type of API (servers, flavors, images,
104     etc.) and provide CRUD operations for them.
105     """
106     resource_class = None
107
108     def __init__(self, client):
109         """Initializes BaseManager with `client`.
110
111         :param client: instance of BaseClient descendant for HTTP requests
112         """
113         super(BaseManager, self).__init__()
114         self.client = client
115
116     def _list(self, url, response_key=None, obj_class=None, json=None):
117         """List the collection.
118
119         :param url: a partial URL, e.g., '/servers'
120         :param response_key: the key to be looked up in response dictionary,
121             e.g., 'servers'. If response_key is None - all response body
122             will be used.
123         :param obj_class: class for constructing the returned objects
124             (self.resource_class will be used by default)
125         :param json: data that will be encoded as JSON and passed in POST
126             request (GET will be sent by default)
127         """
128         if json:
129             body = self.client.post(url, json=json).json()
130         else:
131             body = self.client.get(url).json()
132
133         if obj_class is None:
134             obj_class = self.resource_class
135
136         data = body[response_key] if response_key is not None else body
137         # NOTE(ja): keystone returns values as list as {'values': [ ... ]}
138         #           unlike other services which just return the list...
139         try:
140             data = data['values']
141         except (KeyError, TypeError):
142             pass
143
144         return [obj_class(self, res, loaded=True) for res in data if res]
145
146     def _get(self, url, response_key=None):
147         """Get an object from collection.
148
149         :param url: a partial URL, e.g., '/servers'
150         :param response_key: the key to be looked up in response dictionary,
151             e.g., 'server'. If response_key is None - all response body
152             will be used.
153         """
154         body = self.client.get(url).json()
155         data = body[response_key] if response_key is not None else body
156         return self.resource_class(self, data, loaded=True)
157
158     def _head(self, url):
159         """Retrieve request headers for an object.
160
161         :param url: a partial URL, e.g., '/servers'
162         """
163         resp = self.client.head(url)
164         return resp.status_code == 204
165
166     def _post(self, url, json, response_key=None, return_raw=False):
167         """Create an object.
168
169         :param url: a partial URL, e.g., '/servers'
170         :param json: data that will be encoded as JSON and passed in POST
171             request (GET will be sent by default)
172         :param response_key: the key to be looked up in response dictionary,
173             e.g., 'server'. If response_key is None - all response body
174             will be used.
175         :param return_raw: flag to force returning raw JSON instead of
176             Python object of self.resource_class
177         """
178         body = self.client.post(url, json=json).json()
179         data = body[response_key] if response_key is not None else body
180         if return_raw:
181             return data
182         return self.resource_class(self, data)
183
184     def _put(self, url, json=None, response_key=None):
185         """Update an object with PUT method.
186
187         :param url: a partial URL, e.g., '/servers'
188         :param json: data that will be encoded as JSON and passed in POST
189             request (GET will be sent by default)
190         :param response_key: the key to be looked up in response dictionary,
191             e.g., 'servers'. If response_key is None - all response body
192             will be used.
193         """
194         resp = self.client.put(url, json=json)
195         # PUT requests may not return a body
196         if resp.content:
197             body = resp.json()
198             if response_key is not None:
199                 return self.resource_class(self, body[response_key])
200             else:
201                 return self.resource_class(self, body)
202
203     def _patch(self, url, json=None, response_key=None):
204         """Update an object with PATCH method.
205
206         :param url: a partial URL, e.g., '/servers'
207         :param json: data that will be encoded as JSON and passed in POST
208             request (GET will be sent by default)
209         :param response_key: the key to be looked up in response dictionary,
210             e.g., 'servers'. If response_key is None - all response body
211             will be used.
212         """
213         body = self.client.patch(url, json=json).json()
214         if response_key is not None:
215             return self.resource_class(self, body[response_key])
216         else:
217             return self.resource_class(self, body)
218
219     def _delete(self, url):
220         """Delete an object.
221
222         :param url: a partial URL, e.g., '/servers/my-server'
223         """
224         return self.client.delete(url)
225
226
227 @six.add_metaclass(abc.ABCMeta)
228 class ManagerWithFind(BaseManager):
229     """Manager with additional `find()`/`findall()` methods."""
230
231     @abc.abstractmethod
232     def list(self):
233         pass
234
235     def find(self, **kwargs):
236         """Find a single item with attributes matching ``**kwargs``.
237
238         This isn't very efficient: it loads the entire list then filters on
239         the Python side.
240         """
241         matches = self.findall(**kwargs)
242         num_matches = len(matches)
243         if num_matches == 0:
244             msg = _("No %(name)s matching %(args)s.") % {
245                 'name': self.resource_class.__name__,
246                 'args': kwargs
247             }
248             raise exceptions.NotFound(msg)
249         elif num_matches > 1:
250             raise exceptions.NoUniqueMatch()
251         else:
252             return matches[0]
253
254     def findall(self, **kwargs):
255         """Find all items with attributes matching ``**kwargs``.
256
257         This isn't very efficient: it loads the entire list then filters on
258         the Python side.
259         """
260         found = []
261         searches = kwargs.items()
262
263         for obj in self.list():
264             try:
265                 if all(getattr(obj, attr) == value
266                        for (attr, value) in searches):
267                     found.append(obj)
268             except AttributeError:
269                 continue
270
271         return found
272
273
274 class CrudManager(BaseManager):
275     """Base manager class for manipulating entities.
276
277     Children of this class are expected to define a `collection_key` and `key`.
278
279     - `collection_key`: Usually a plural noun by convention (e.g. `entities`);
280       used to refer collections in both URL's (e.g.  `/v3/entities`) and JSON
281       objects containing a list of member resources (e.g. `{'entities': [{},
282       {}, {}]}`).
283     - `key`: Usually a singular noun by convention (e.g. `entity`); used to
284       refer to an individual member of the collection.
285
286     """
287     collection_key = None
288     key = None
289
290     def build_url(self, base_url=None, **kwargs):
291         """Builds a resource URL for the given kwargs.
292
293         Given an example collection where `collection_key = 'entities'` and
294         `key = 'entity'`, the following URL's could be generated.
295
296         By default, the URL will represent a collection of entities, e.g.::
297
298             /entities
299
300         If kwargs contains an `entity_id`, then the URL will represent a
301         specific member, e.g.::
302
303             /entities/{entity_id}
304
305         :param base_url: if provided, the generated URL will be appended to it
306         """
307         url = base_url if base_url is not None else ''
308
309         url += '/%s' % self.collection_key
310
311         # do we have a specific entity?
312         entity_id = kwargs.get('%s_id' % self.key)
313         if entity_id is not None:
314             url += '/%s' % entity_id
315
316         return url
317
318     def _filter_kwargs(self, kwargs):
319         """Drop null values and handle ids."""
320         for key, ref in six.iteritems(kwargs.copy()):
321             if ref is None:
322                 kwargs.pop(key)
323             else:
324                 if isinstance(ref, Resource):
325                     kwargs.pop(key)
326                     kwargs['%s_id' % key] = getid(ref)
327         return kwargs
328
329     def create(self, **kwargs):
330         kwargs = self._filter_kwargs(kwargs)
331         return self._post(
332             self.build_url(**kwargs),
333             {self.key: kwargs},
334             self.key)
335
336     def get(self, **kwargs):
337         kwargs = self._filter_kwargs(kwargs)
338         return self._get(
339             self.build_url(**kwargs),
340             self.key)
341
342     def head(self, **kwargs):
343         kwargs = self._filter_kwargs(kwargs)
344         return self._head(self.build_url(**kwargs))
345
346     def list(self, base_url=None, **kwargs):
347         """List the collection.
348
349         :param base_url: if provided, the generated URL will be appended to it
350         """
351         kwargs = self._filter_kwargs(kwargs)
352
353         return self._list(
354             '%(base_url)s%(query)s' % {
355                 'base_url': self.build_url(base_url=base_url, **kwargs),
356                 'query': '?%s' % parse.urlencode(kwargs) if kwargs else '',
357             },
358             self.collection_key)
359
360     def put(self, base_url=None, **kwargs):
361         """Update an element.
362
363         :param base_url: if provided, the generated URL will be appended to it
364         """
365         kwargs = self._filter_kwargs(kwargs)
366
367         return self._put(self.build_url(base_url=base_url, **kwargs))
368
369     def update(self, **kwargs):
370         kwargs = self._filter_kwargs(kwargs)
371         params = kwargs.copy()
372         params.pop('%s_id' % self.key)
373
374         return self._patch(
375             self.build_url(**kwargs),
376             {self.key: params},
377             self.key)
378
379     def delete(self, **kwargs):
380         kwargs = self._filter_kwargs(kwargs)
381
382         return self._delete(
383             self.build_url(**kwargs))
384
385     def find(self, base_url=None, **kwargs):
386         """Find a single item with attributes matching ``**kwargs``.
387
388         :param base_url: if provided, the generated URL will be appended to it
389         """
390         kwargs = self._filter_kwargs(kwargs)
391
392         rl = self._list(
393             '%(base_url)s%(query)s' % {
394                 'base_url': self.build_url(base_url=base_url, **kwargs),
395                 'query': '?%s' % parse.urlencode(kwargs) if kwargs else '',
396             },
397             self.collection_key)
398         num = len(rl)
399
400         if num == 0:
401             msg = _("No %(name)s matching %(args)s.") % {
402                 'name': self.resource_class.__name__,
403                 'args': kwargs
404             }
405             raise exceptions.NotFound(404, msg)
406         elif num > 1:
407             raise exceptions.NoUniqueMatch
408         else:
409             return rl[0]
410
411
412 class Extension(HookableMixin):
413     """Extension descriptor."""
414
415     SUPPORTED_HOOKS = ('__pre_parse_args__', '__post_parse_args__')
416     manager_class = None
417
418     def __init__(self, name, module):
419         super(Extension, self).__init__()
420         self.name = name
421         self.module = module
422         self._parse_extension_module()
423
424     def _parse_extension_module(self):
425         self.manager_class = None
426         for attr_name, attr_value in self.module.__dict__.items():
427             if attr_name in self.SUPPORTED_HOOKS:
428                 self.add_hook(attr_name, attr_value)
429             else:
430                 try:
431                     if issubclass(attr_value, BaseManager):
432                         self.manager_class = attr_value
433                 except TypeError:
434                     pass
435
436     def __repr__(self):
437         return "<Extension '%s'>" % self.name
438
439
440 class Resource(object):
441     """Base class for OpenStack resources (tenant, user, etc.).
442
443     This is pretty much just a bag for attributes.
444     """
445
446     HUMAN_ID = False
447     NAME_ATTR = 'name'
448
449     def __init__(self, manager, info, loaded=False):
450         """Populate and bind to a manager.
451
452         :param manager: BaseManager object
453         :param info: dictionary representing resource attributes
454         :param loaded: prevent lazy-loading if set to True
455         """
456         self.manager = manager
457         self._info = info
458         self._add_details(info)
459         self._loaded = loaded
460
461     def __repr__(self):
462         reprkeys = sorted(k
463                           for k in self.__dict__.keys()
464                           if k[0] != '_' and k != 'manager')
465         info = ", ".join("%s=%s" % (k, getattr(self, k)) for k in reprkeys)
466         return "<%s %s>" % (self.__class__.__name__, info)
467
468     @property
469     def human_id(self):
470         """Human-readable ID which can be used for bash completion.
471         """
472         if self.HUMAN_ID:
473             name = getattr(self, self.NAME_ATTR, None)
474             if name is not None:
475                 return strutils.to_slug(name)
476         return None
477
478     def _add_details(self, info):
479         for (k, v) in six.iteritems(info):
480             try:
481                 setattr(self, k, v)
482                 self._info[k] = v
483             except AttributeError:
484                 # In this case we already defined the attribute on the class
485                 pass
486
487     def __getattr__(self, k):
488         if k not in self.__dict__:
489             # NOTE(bcwaldon): disallow lazy-loading if already loaded once
490             if not self.is_loaded():
491                 self.get()
492                 return self.__getattr__(k)
493
494             raise AttributeError(k)
495         else:
496             return self.__dict__[k]
497
498     def get(self):
499         """Support for lazy loading details.
500
501         Some clients, such as novaclient have the option to lazy load the
502         details, details which can be loaded with this function.
503         """
504         # set_loaded() first ... so if we have to bail, we know we tried.
505         self.set_loaded(True)
506         if not hasattr(self.manager, 'get'):
507             return
508
509         new = self.manager.get(self.id)
510         if new:
511             self._add_details(new._info)
512             self._add_details(
513                 {'x_request_id': self.manager.client.last_request_id})
514
515     def __eq__(self, other):
516         if not isinstance(other, Resource):
517             return NotImplemented
518         # two resources of different types are not equal
519         if not isinstance(other, self.__class__):
520             return False
521         if hasattr(self, 'id') and hasattr(other, 'id'):
522             return self.id == other.id
523         return self._info == other._info
524
525     def is_loaded(self):
526         return self._loaded
527
528     def set_loaded(self, val):
529         self._loaded = val
530
531     def to_dict(self):
532         return copy.deepcopy(self._info)