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