1# Licensed under the Apache License, Version 2.0 (the "License"); you may 2# not use this file except in compliance with the License. You may obtain 3# a copy of the License at 4# 5# http://www.apache.org/licenses/LICENSE-2.0 6# 7# Unless required by applicable law or agreed to in writing, software 8# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 9# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 10# License for the specific language governing permissions and limitations 11# under the License. 12# 13 14"""Base API Library""" 15 16from keystoneauth1 import exceptions as ksa_exceptions 17from keystoneauth1 import session as ksa_session 18import simplejson as json 19 20from osc_lib import exceptions 21from osc_lib.i18n import _ 22 23 24class BaseAPI(object): 25 """Base API wrapper for keystoneauth1.session.Session 26 27 Encapsulate the translation between keystoneauth1.session.Session 28 and requests.Session in a single layer: 29 30 * Restore some requests.session.Session compatibility; 31 keystoneauth1.session.Session.request() has the method and url 32 arguments swapped from the rest of the requests-using world. 33 * Provide basic endpoint handling when a Service Catalog is not 34 available. 35 36 """ 37 38 # Which service are we? Set in API-specific subclasses 39 SERVICE_TYPE = "" 40 41 # The common OpenStack microversion header 42 HEADER_NAME = "OpenStack-API-Version" 43 44 def __init__( 45 self, 46 session=None, 47 service_type=None, 48 endpoint=None, 49 **kwargs 50 ): 51 """Base object that contains some common API objects and methods 52 53 :param keystoneauth1.session.Session session: 54 The session to be used for making the HTTP API calls. If None, 55 a default keystoneauth1.session.Session will be created. 56 :param string service_type: 57 API name, i.e. ``identity`` or ``compute`` 58 :param string endpoint: 59 An optional URL to be used as the base for API requests on 60 this API. 61 :param kwargs: 62 Keyword arguments passed to keystoneauth1.session.Session(). 63 """ 64 65 super(BaseAPI, self).__init__() 66 67 # Create a keystoneauth1.session.Session if one is not supplied 68 if not session: 69 self.session = ksa_session.Session(**kwargs) 70 else: 71 self.session = session 72 73 self.service_type = service_type 74 self.endpoint = self._munge_endpoint(endpoint) 75 76 def _munge_endpoint(self, endpoint): 77 """Hook to allow subclasses to massage the passed-in endpoint 78 79 Hook to massage passed-in endpoints from arbitrary sources, 80 including direct user input. By default just remove trailing 81 '/' as all of our path info strings start with '/' and not all 82 services can handle '//' in their URLs. 83 84 Some subclasses will override this to do additional work, most 85 likely with regard to API versions. 86 87 :param string endpoint: The service endpoint, generally direct 88 from the service catalog. 89 :return: The modified endpoint 90 """ 91 92 if isinstance(endpoint, str): 93 return endpoint.rstrip('/') 94 else: 95 return endpoint 96 97 def _request(self, method, url, session=None, **kwargs): 98 """Perform call into session 99 100 All API calls are funneled through this method to provide a common 101 place to finalize the passed URL and other things. 102 103 :param string method: 104 The HTTP method name, i.e. ``GET``, ``PUT``, etc 105 :param string url: 106 The API-specific portion of the URL path, or a full URL if 107 ``endpoint`` was not supplied at initialization. 108 :param keystoneauth1.session.Session session: 109 An initialized session to override the one created at 110 initialization. 111 :param kwargs: 112 Keyword arguments passed to requests.request(). 113 :return: the requests.Response object 114 """ 115 116 # If session arg is supplied, use it this time, but don't save it 117 if not session: 118 session = self.session 119 120 # Do the auto-endpoint magic 121 if self.endpoint: 122 if url: 123 url = '/'.join([self.endpoint.rstrip('/'), url.lstrip('/')]) 124 else: 125 # NOTE(dtroyer): This is left here after _munge_endpoint() is 126 # added because endpoint is public and there is 127 # no accounting for what may happen. 128 url = self.endpoint.rstrip('/') 129 else: 130 # Pass on the lack of URL unmolested to maintain the same error 131 # handling from keystoneauth: raise EndpointNotFound 132 pass 133 134 # Hack out empty headers 'cause KSA can't stomach it 135 if 'headers' in kwargs and kwargs['headers'] is None: 136 kwargs.pop('headers') 137 138 # Why is ksc session backwards??? 139 return session.request(url, method, **kwargs) 140 141 # The basic action methods all take a Session and return dict/lists 142 143 def create( 144 self, 145 url, 146 session=None, 147 method=None, 148 **params 149 ): 150 """Create a new resource 151 152 :param string url: 153 The API-specific portion of the URL path 154 :param Session session: 155 HTTP client session 156 :param string method: 157 HTTP method (default POST) 158 """ 159 160 if not method: 161 method = 'POST' 162 ret = self._request(method, url, session=session, **params) 163 # Should this move into _requests()? 164 try: 165 return ret.json() 166 except json.JSONDecodeError: 167 return ret 168 169 def delete( 170 self, 171 url, 172 session=None, 173 **params 174 ): 175 """Delete a resource 176 177 :param string url: 178 The API-specific portion of the URL path 179 :param Session session: 180 HTTP client session 181 """ 182 183 return self._request('DELETE', url, **params) 184 185 def list( 186 self, 187 path, 188 session=None, 189 body=None, 190 detailed=False, 191 headers=None, 192 **params 193 ): 194 """Return a list of resources 195 196 GET ${ENDPOINT}/${PATH}?${PARAMS} 197 198 path is often the object's plural resource type 199 200 :param string path: 201 The API-specific portion of the URL path 202 :param Session session: 203 HTTP client session 204 :param body: data that will be encoded as JSON and passed in POST 205 request (GET will be sent by default) 206 :param bool detailed: 207 Adds '/details' to path for some APIs to return extended attributes 208 :param dict headers: 209 Headers dictionary to pass to requests 210 :returns: 211 JSON-decoded response, could be a list or a dict-wrapped-list 212 """ 213 214 if detailed: 215 path = '/'.join([path.rstrip('/'), 'details']) 216 217 if body: 218 ret = self._request( 219 'POST', 220 path, 221 # service=self.service_type, 222 json=body, 223 params=params, 224 headers=headers, 225 ) 226 else: 227 ret = self._request( 228 'GET', 229 path, 230 # service=self.service_type, 231 params=params, 232 headers=headers, 233 ) 234 try: 235 return ret.json() 236 except json.JSONDecodeError: 237 return ret 238 239 # Layered actions built on top of the basic action methods do not 240 # explicitly take a Session but one may still be passed in kwargs 241 242 def find_attr( 243 self, 244 path, 245 value=None, 246 attr=None, 247 resource=None, 248 ): 249 """Find a resource via attribute or ID 250 251 Most APIs return a list wrapped by a dict with the resource 252 name as key. Some APIs (Identity) return a dict when a query 253 string is present and there is one return value. Take steps to 254 unwrap these bodies and return a single dict without any resource 255 wrappers. 256 257 :param string path: 258 The API-specific portion of the URL path 259 :param string value: 260 value to search for 261 :param string attr: 262 attribute to use for resource search 263 :param string resource: 264 plural of the object resource name; defaults to path 265 266 For example: 267 n = find(netclient, 'network', 'networks', 'matrix') 268 """ 269 270 # Default attr is 'name' 271 if attr is None: 272 attr = 'name' 273 274 # Default resource is path - in many APIs they are the same 275 if resource is None: 276 resource = path 277 278 def getlist(kw): 279 """Do list call, unwrap resource dict if present""" 280 ret = self.list(path, **kw) 281 if isinstance(ret, dict) and resource in ret: 282 ret = ret[resource] 283 return ret 284 285 # Search by attribute 286 kwargs = {attr: value} 287 data = getlist(kwargs) 288 if isinstance(data, dict): 289 return data 290 if len(data) == 1: 291 return data[0] 292 if len(data) > 1: 293 msg = _("Multiple %(resource)s exist with %(attr)s='%(value)s'") 294 raise exceptions.CommandError( 295 msg % {'resource': resource, 296 'attr': attr, 297 'value': value} 298 ) 299 300 # Search by id 301 kwargs = {'id': value} 302 data = getlist(kwargs) 303 if len(data) == 1: 304 return data[0] 305 msg = _("No %(resource)s with a %(attr)s or ID of '%(value)s' found") 306 raise exceptions.CommandError( 307 msg % {'resource': resource, 308 'attr': attr, 309 'value': value} 310 ) 311 312 def find_bulk( 313 self, 314 path, 315 headers=None, 316 **kwargs 317 ): 318 """Bulk load and filter locally 319 320 :param string path: 321 The API-specific portion of the URL path 322 :param kwargs: 323 A dict of AVPs to match - logical AND 324 :param dict headers: 325 Headers dictionary to pass to requests 326 :returns: list of resource dicts 327 """ 328 329 items = self.list(path) 330 if isinstance(items, dict): 331 # strip off the enclosing dict 332 key = list(items.keys())[0] 333 items = items[key] 334 335 ret = [] 336 for o in items: 337 try: 338 if all(o[attr] == kwargs[attr] for attr in kwargs.keys()): 339 ret.append(o) 340 except KeyError: 341 continue 342 343 return ret 344 345 def find_one( 346 self, 347 path, 348 **kwargs 349 ): 350 """Find a resource by name or ID 351 352 :param string path: 353 The API-specific portion of the URL path 354 :returns: 355 resource dict 356 """ 357 358 bulk_list = self.find_bulk(path, **kwargs) 359 num_bulk = len(bulk_list) 360 if num_bulk == 0: 361 msg = _("none found") 362 raise exceptions.NotFound(404, msg) 363 elif num_bulk > 1: 364 msg = _("many found") 365 raise RuntimeError(msg) 366 return bulk_list[0] 367 368 def find( 369 self, 370 path, 371 value=None, 372 attr=None, 373 headers=None, 374 ): 375 """Find a single resource by name or ID 376 377 :param string path: 378 The API-specific portion of the URL path 379 :param string value: 380 search expression (required, really) 381 :param string attr: 382 name of attribute for secondary search 383 :param dict headers: 384 Headers dictionary to pass to requests 385 """ 386 387 def raise_not_found(): 388 msg = _("%s not found") % value 389 raise exceptions.NotFound(404, msg) 390 391 try: 392 ret = self._request( 393 'GET', "/%s/%s" % (path, value), 394 headers=headers, 395 ).json() 396 if isinstance(ret, dict): 397 # strip off the enclosing dict 398 key = list(ret.keys())[0] 399 ret = ret[key] 400 except ( 401 ksa_exceptions.NotFound, 402 ksa_exceptions.BadRequest, 403 ): 404 if attr: 405 kwargs = {attr: value} 406 try: 407 ret = self.find_one( 408 path, 409 headers=headers, 410 **kwargs 411 ) 412 except ( 413 exceptions.NotFound, 414 ksa_exceptions.NotFound, 415 ): 416 raise_not_found() 417 else: 418 raise_not_found() 419 420 return ret 421