__init__.py 34.5 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
# Copyright (C) 2012, 2013 GRNET S.A. All rights reserved.
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following
# conditions are met:
#
#   1. Redistributions of source code must retain the above
#      copyright notice, this list of conditions and the following
#      disclaimer.
#
#   2. Redistributions in binary form must reproduce the above
#      copyright notice, this list of conditions and the following
#      disclaimer in the documentation and/or other materials
#      provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# The views and conclusions contained in the software and
# documentation are those of the authors and should not be
# interpreted as representing official policies, either expressed
# or implied, of GRNET S.A.
33

34 35 36 37
"""
Simple and minimal client for the Astakos authentication service
"""

38 39
import logging
import urlparse
40
import urllib
41
import hashlib
42
from copy import copy
43 44

import simplejson
45
from astakosclient.utils import \
46
    retry_dec, scheme_to_class, parse_request, check_input, join_urls
47
from astakosclient.errors import \
48
    AstakosClientException, Unauthorized, BadRequest, NotFound, Forbidden, \
49
    NoUserName, NoUUID, BadValue, QuotaLimit, InvalidResponse, NoEndpoints
50 51


52
# --------------------------------------------------------------------
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
53
# Astakos Client Class
54

55
def get_token_from_cookie(request, cookie_name):
56 57 58 59 60 61 62 63 64 65
    """Extract token from the cookie name provided

    Cookie should be in the same form as astakos
    service sets its cookie contents:
        <user_uniq>|<user_token>

    """
    try:
        cookie_content = urllib.unquote(request.COOKIE.get(cookie_name, None))
        return cookie_content.split("|")[1]
66
    except BaseException:
67 68 69
        return None


70 71 72
# Too many instance attributes. pylint: disable-msg=R0902
# Too many public methods. pylint: disable-msg=R0904
class AstakosClient(object):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
73 74 75
    """AstakosClient Class Implementation"""

    # ----------------------------------
76 77 78 79 80 81
    # Initialize AstakosClient Class
    # Too many arguments. pylint: disable-msg=R0913
    # Too many local variables. pylint: disable-msg=R0914
    # Too many statements. pylint: disable-msg=R0915
    def __init__(self, token, auth_url,
                 retry=0, use_pool=False, pool_size=8, logger=None):
82
        """Initialize AstakosClient Class
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
83 84

        Keyword arguments:
85 86
        token       -- user's/service's token (string)
        auth_url    -- i.e https://accounts.example.com/identity/v2.0
87
        retry       -- how many time to retry (integer)
88 89
        use_pool    -- use objpool for http requests (boolean)
        pool_size   -- if using pool, define the pool size
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
90 91 92
        logger      -- pass a different logger

        """
93 94

        # Get logger
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
95
        if logger is None:
96 97 98 99
            logging.basicConfig(
                format='%(asctime)s [%(levelname)s] %(name)s %(message)s',
                datefmt='%Y-%m-%d %H:%M:%S',
                level=logging.INFO)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
100
            logger = logging.getLogger("astakosclient")
101 102 103
        logger.debug("Intialize AstakosClient: auth_url = %s, "
                     "use_pool = %s, pool_size = %s",
                     auth_url, use_pool, pool_size)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
104

105 106
        # Check that token and auth_url (mandatory options) are given
        check_input("__init__", logger, token=token, auth_url=auth_url)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
107

108 109 110 111
        # Initialize connection class
        parsed_auth_url = urlparse.urlparse(auth_url)
        conn_class = \
            scheme_to_class(parsed_auth_url.scheme, use_pool, pool_size)
112
        if conn_class is None:
113 114 115
            msg = "Unsupported scheme: %s" % parsed_auth_url.scheme
            logger.error(msg)
            raise BadValue(msg)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
116

117
        # Save astakos base url, logger, connection class etc in our class
118
        self.retry = retry
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
119
        self.logger = logger
120 121 122
        self.token = token
        self.astakos_base_url = parsed_auth_url.netloc
        self.scheme = parsed_auth_url.scheme
123
        self.conn_class = conn_class
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
124

125 126 127 128 129
        # Initialize astakos api prefixes
        # API urls under auth_url
        self.auth_prefix = parsed_auth_url.path
        self.api_tokens = join_urls(self.auth_prefix, "tokens")

130 131
    def _fill_endpoints(self, endpoints):
        astakos_service_catalog = parse_endpoints(
132
            endpoints, ep_name="astakos_account", ep_version_id="v1.0")
133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194
        self._account_url = \
            astakos_service_catalog[0]['endpoints'][0]['publicURL']
        parsed_account_url = urlparse.urlparse(self._account_url)

        self._account_prefix = parsed_account_url.path
        self.logger.debug("Got account_prefix \"%s\"" % self._account_prefix)

        self._ui_url = \
            astakos_service_catalog[0]['endpoints'][0]['SNF:uiURL']
        parsed_ui_url = urlparse.urlparse(self._ui_url)

        self._ui_prefix = parsed_ui_url.path
        self.logger.debug("Got ui_prefix \"%s\"" % self._ui_prefix)

    def _get_value(self, s):
        assert s in ['_account_url', '_account_prefix',
                     '_ui_url', '_ui_prefix']
        try:
            return getattr(self, s)
        except AttributeError:
            self.get_endpoints()
            return getattr(self, s)

    @property
    def account_url(self):
        return self._get_value('_account_url')

    @property
    def account_prefix(self):
        return self._get_value('_account_prefix')

    @property
    def ui_url(self):
        return self._get_value('_ui_url')

    @property
    def ui_prefix(self):
        return self._get_value('_ui_prefix')

    @property
    def api_authenticate(self):
        return join_urls(self.account_prefix, "authenticate")

    @property
    def api_usercatalogs(self):
        return join_urls(self.account_prefix, "user_catalogs")

    @property
    def api_service_usercatalogs(self):
        return join_urls(self.account_prefix, "service/user_catalogs")

    @property
    def api_resources(self):
        return join_urls(self.account_prefix, "resources")

    @property
    def api_quotas(self):
        return join_urls(self.account_prefix, "quotas")

    @property
    def api_service_quotas(self):
        return join_urls(self.account_prefix, "service_quotas")
195

196 197 198
    @property
    def api_commissions(self):
        return join_urls(self.account_prefix, "commissions")
199

200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222
    @property
    def api_commissions_action(self):
        return join_urls(self.api_commissions, "action")

    @property
    def api_feedback(self):
        return join_urls(self.account_prefix, "feedback")

    @property
    def api_projects(self):
        return join_urls(self.account_prefix, "projects")

    @property
    def api_applications(self):
        return join_urls(self.api_projects, "apps")

    @property
    def api_memberships(self):
        return join_urls(self.api_projects, "memberships")

    @property
    def api_getservices(self):
        return join_urls(self.ui_prefix, "get_services")
223

224
    # ----------------------------------
225 226
    @retry_dec
    def _call_astakos(self, request_path, headers=None,
227
                      body=None, method="GET", log_body=True):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
228
        """Make the actual call to Astakos Service"""
229 230
        hashed_token = hashlib.sha1()
        hashed_token.update(self.token)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
231
        self.logger.debug(
232 233 234 235
            "Make a %s request to %s, using token with hash %s, "
            "with headers %s and body %s",
            method, request_path, hashed_token.hexdigest(), headers,
            body if log_body else "(not logged)")
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
236

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
237
        # Check Input
238 239 240 241
        if headers is None:
            headers = {}
        if body is None:
            body = {}
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
242

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
243 244
        # Build request's header and body
        kwargs = {}
245
        kwargs['headers'] = copy(headers)
246
        kwargs['headers']['X-Auth-Token'] = self.token
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
247
        if body:
248
            kwargs['body'] = copy(body)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
249 250 251 252 253 254
            kwargs['headers'].setdefault(
                'content-type', 'application/octet-stream')
        kwargs['headers'].setdefault('content-length',
                                     len(body) if body else 0)

        try:
255
            # Get the connection object
256
            with self.conn_class(self.astakos_base_url) as conn:
257
                # Send request
258
                # Used * or ** magic. pylint: disable-msg=W0142
259
                (message, data, status) = \
260
                    _do_request(conn, method, request_path, **kwargs)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
261
        except Exception as err:
262
            self.logger.error("Failed to send request: %s" % repr(err))
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
263 264 265 266
            raise AstakosClientException(str(err))

        # Return
        self.logger.debug("Request returned with status %s" % status)
267
        if status == 400:
268
            raise BadRequest(message, data)
269
        elif status == 401:
270
            raise Unauthorized(message, data)
271
        elif status == 403:
272
            raise Forbidden(message, data)
273
        elif status == 404:
274
            raise NotFound(message, data)
275
        elif status < 200 or status >= 300:
276
            raise AstakosClientException(message, data, status)
277 278 279 280 281

        try:
            if data:
                return simplejson.loads(unicode(data))
            else:
282
                return None
283
        except Exception as err:
284 285
            msg = "Cannot parse response \"%s\" with simplejson: %s"
            self.logger.error(msg % (data, str(err)))
286
            raise InvalidResponse(str(err), data)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
287 288

    # ------------------------
289
    # do a GET to ``API_AUTHENTICATE``
290
    def get_user_info(self):
291
        """Authenticate user and get user's info as a dictionary
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
292 293 294 295 296

        In case of success return user information (json parsed format).
        Otherwise raise an AstakosClientException.

        """
297
        return self._call_astakos(self.api_authenticate)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
298 299

    # ----------------------------------
300
    # do a POST to ``API_USERCATALOGS`` (or ``API_SERVICE_USERCATALOGS``)
301
    #   with {'uuids': uuids}
302 303
    def _uuid_catalog(self, uuids, req_path):
        """Helper function to retrieve uuid catalog"""
304
        req_headers = {'content-type': 'application/json'}
305
        req_body = parse_request({'uuids': uuids}, self.logger)
306 307
        data = self._call_astakos(req_path, headers=req_headers,
                                  body=req_body, method="POST")
308 309 310
        if "uuid_catalog" in data:
            return data.get("uuid_catalog")
        else:
311 312 313 314
            msg = "_uuid_catalog request returned %s. No uuid_catalog found" \
                  % data
            self.logger.error(msg)
            raise AstakosClientException(msg)
315

316
    def get_usernames(self, uuids):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
317 318 319 320 321 322 323 324 325
        """Return a uuid_catalog dictionary for the given uuids

        Keyword arguments:
        uuids   -- list of user ids (list of strings)

        The returned uuid_catalog is a dictionary with uuids as
        keys and the corresponding user names as values

        """
326
        return self._uuid_catalog(uuids, self.api_usercatalogs)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
327

328
    def get_username(self, uuid):
329
        """Return the user name of a uuid (see get_usernames)"""
330
        check_input("get_username", self.logger, uuid=uuid)
331
        uuid_dict = self.get_usernames([uuid])
332 333 334
        if uuid in uuid_dict:
            return uuid_dict.get(uuid)
        else:
335
            raise NoUserName(uuid)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
336

337
    def service_get_usernames(self, uuids):
338
        """Return a uuid_catalog dict using a service's token"""
339
        return self._uuid_catalog(uuids, self.api_service_usercatalogs)
340

341
    def service_get_username(self, uuid):
342
        """Return the displayName of a uuid using a service's token"""
343
        check_input("service_get_username", self.logger, uuid=uuid)
344
        uuid_dict = self.service_get_usernames([uuid])
345 346 347
        if uuid in uuid_dict:
            return uuid_dict.get(uuid)
        else:
348
            raise NoUserName(uuid)
349

350
    # ----------------------------------
351
    # do a POST to ``API_USERCATALOGS`` (or ``API_SERVICE_USERCATALOGS``)
352
    #   with {'displaynames': display_names}
353 354
    def _displayname_catalog(self, display_names, req_path):
        """Helper function to retrieve display names catalog"""
355
        req_headers = {'content-type': 'application/json'}
356
        req_body = parse_request({'displaynames': display_names}, self.logger)
357 358
        data = self._call_astakos(req_path, headers=req_headers,
                                  body=req_body, method="POST")
359 360 361
        if "displayname_catalog" in data:
            return data.get("displayname_catalog")
        else:
362 363 364 365
            msg = "_displayname_catalog request returned %s. " \
                  "No displayname_catalog found" % data
            self.logger.error(msg)
            raise AstakosClientException(msg)
366

367
    def get_uuids(self, display_names):
368 369 370 371 372 373 374 375 376
        """Return a displayname_catalog for the given names

        Keyword arguments:
        display_names   -- list of user names (list of strings)

        The returned displayname_catalog is a dictionary with
        the names as keys and the corresponding uuids as values

        """
377 378
        return self._displayname_catalog(
            display_names, self.api_usercatalogs)
379

380
    def get_uuid(self, display_name):
381
        """Return the uuid of a name (see getUUIDs)"""
382
        check_input("get_uuid", self.logger, display_name=display_name)
383
        name_dict = self.get_uuids([display_name])
384 385 386 387
        if display_name in name_dict:
            return name_dict.get(display_name)
        else:
            raise NoUUID(display_name)
388

389
    def service_get_uuids(self, display_names):
390
        """Return a display_name catalog using a service's token"""
391 392
        return self._displayname_catalog(
            display_names, self.api_service_usercatalogs)
393

394
    def service_get_uuid(self, display_name):
395
        """Return the uuid of a name using a service's token"""
396
        check_input("service_get_uuid", self.logger, display_name=display_name)
397
        name_dict = self.service_get_uuids([display_name])
398 399 400 401
        if display_name in name_dict:
            return name_dict.get(display_name)
        else:
            raise NoUUID(display_name)
402

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
403
    # ----------------------------------
404
    # do a GET to ``API_GETSERVICES``
405
    def get_services(self):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
406
        """Return a list of dicts with the registered services"""
407
        return self._call_astakos(self.api_getservices)
408 409

    # ----------------------------------
410
    # do a GET to ``API_RESOURCES``
411 412
    def get_resources(self):
        """Return a dict of dicts with the available resources"""
413
        return self._call_astakos(self.api_resources)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
414

415 416
    # ----------------------------------
    # do a POST to ``API_FEEDBACK``
417
    def send_feedback(self, message, data):
418 419 420 421 422 423 424 425 426 427 428 429 430
        """Send feedback to astakos service

        keyword arguments:
        message     -- Feedback message
        data        -- Additional information about service client status

        In case of success return nothing.
        Otherwise raise an AstakosClientException

        """
        check_input("send_feedback", self.logger, message=message, data=data)
        req_body = urllib.urlencode(
            {'feedback_msg': message, 'feedback_data': data})
431 432
        self._call_astakos(self.api_feedback, headers=None,
                           body=req_body, method="POST")
433

434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452
    # -----------------------------------------
    # do a POST to ``API_TOKENS`` with no token
    def get_endpoints(self):
        """ Get services' endpoints

        In case of error raise an AstakosClientException.

        """
        req_headers = {'content-type': 'application/json'}
        req_body = None
        r = self._call_astakos(self.api_tokens, headers=req_headers,
                               body=req_body, method="POST",
                               log_body=False)
        self._fill_endpoints(r)
        return r

    # --------------------------------------
    # do a POST to ``API_TOKENS`` with a token
    def authenticate(self, tenant_name=None):
453
        """ Authenticate and get services' endpoints
454 455

        Keyword arguments:
456
        tenant_name         -- user's uniq id (optional)
457 458

        It returns back the token as well as information about the token
459
        holder and the services he/she can access (in json format).
460 461 462 463

        The tenant_name is optional and if it is given it must match the
        user's uuid.

464 465 466 467
        In case of error raise an AstakosClientException.

        """
        req_headers = {'content-type': 'application/json'}
468 469 470 471 472 473 474 475 476
        body = {'auth': {'token': {'id': self.token}}}
        if tenant_name is not None:
            body['auth']['tenantName'] = tenant_name
        req_body = parse_request(body, self.logger)
        r = self._call_astakos(self.api_tokens, headers=req_headers,
                               body=req_body, method="POST",
                               log_body=False)
        self._fill_endpoints(r)
        return r
477

478
    # ----------------------------------
479
    # do a GET to ``API_QUOTAS``
480
    def get_quotas(self):
481 482 483 484 485 486
        """Get user's quotas

        In case of success return a dict of dicts with user's current quotas.
        Otherwise raise an AstakosClientException

        """
487
        return self._call_astakos(self.api_quotas)
488

489
    # ----------------------------------
490
    # do a GET to ``API_SERVICE_QUOTAS``
491
    def service_get_quotas(self, user=None):
492 493 494
        """Get all quotas for resources associated with the service

        Keyword arguments:
495
        user    -- optionally, the uuid of a specific user
496 497

        In case of success return a dict of dicts of dicts with current quotas
498
        for all users, or of a specified user, if user argument is set.
499 500 501
        Otherwise raise an AstakosClientException

        """
502
        query = self.api_service_quotas
503 504
        if user is not None:
            query += "?user=" + user
505
        return self._call_astakos(query)
506

507
    # ----------------------------------
508
    # do a POST to ``API_COMMISSIONS``
509
    def issue_commission(self, request):
510 511 512 513 514 515 516 517 518 519
        """Issue a commission

        Keyword arguments:
        request -- commision request (dict)

        In case of success return commission's id (int).
        Otherwise raise an AstakosClientException.

        """
        req_headers = {'content-type': 'application/json'}
520
        req_body = parse_request(request, self.logger)
521
        try:
522 523 524 525
            response = self._call_astakos(self.api_commissions,
                                          headers=req_headers,
                                          body=req_body,
                                          method="POST")
526 527 528 529 530 531 532 533 534
        except AstakosClientException as err:
            if err.status == 413:
                raise QuotaLimit(err.message, err.details)
            else:
                raise

        if "serial" in response:
            return response['serial']
        else:
535 536 537 538
            msg = "issue_commission_core request returned %s. " + \
                  "No serial found" % response
            self.logger.error(msg)
            raise AstakosClientException(msg)
539

540
    def issue_one_commission(self, holder, source, provisions,
541
                             name="", force=False, auto_accept=False):
542 543 544 545 546
        """Issue one commission (with specific holder and source)

        keyword arguments:
        holder      -- user's id (string)
        source      -- commission's source (ex system) (string)
547
        provisions  -- resources with their quantity (dict from string to int)
548
        name        -- description of the commission (string)
549 550 551 552 553 554 555 556
        force       -- force this commission (boolean)
        auto_accept -- auto accept this commission (boolean)

        In case of success return commission's id (int).
        Otherwise raise an AstakosClientException.
        (See also issue_commission)

        """
557 558 559
        check_input("issue_one_commission", self.logger,
                    holder=holder, source=source,
                    provisions=provisions)
560 561 562 563

        request = {}
        request["force"] = force
        request["auto_accept"] = auto_accept
564
        request["name"] = name
565 566
        try:
            request["provisions"] = []
567
            for resource, quantity in provisions.iteritems():
568 569 570
                prov = {"holder": holder, "source": source,
                        "resource": resource, "quantity": quantity}
                request["provisions"].append(prov)
571 572 573 574
        except Exception as err:
            self.logger.error(str(err))
            raise BadValue(str(err))

575
        return self.issue_commission(request)
576

577
    # ----------------------------------
578
    # do a GET to ``API_COMMISSIONS``
579
    def get_pending_commissions(self):
580 581 582 583 584 585
        """Get Pending Commissions

        In case of success return a list of pending commissions' ids
        (list of integers)

        """
586
        return self._call_astakos(self.api_commissions)
587

588
    # ----------------------------------
589
    # do a GET to ``API_COMMISSIONS``/<serial>
590
    def get_commission_info(self, serial):
591 592 593 594 595 596
        """Get Description of a Commission

        Keyword arguments:
        serial  -- commission's id (int)

        In case of success return a dict of dicts containing
597
        informations (details) about the requested commission
598 599

        """
600
        check_input("get_commission_info", self.logger, serial=serial)
601

602 603
        path = self.api_commissions.rstrip('/') + "/" + str(serial)
        return self._call_astakos(path)
604

605
    # ----------------------------------
606
    # do a POST to ``API_COMMISSIONS``/<serial>/action"
607
    def commission_action(self, serial, action):
608
        """Perform a commission action
609 610 611 612 613 614 615 616

        Keyword arguments:
        serial  -- commission's id (int)
        action  -- action to perform, currently accept/reject (string)

        In case of success return nothing.

        """
617 618
        check_input("commission_action", self.logger,
                    serial=serial, action=action)
619

620
        path = self.api_commissions.rstrip('/') + "/" + str(serial) + "/action"
621
        req_headers = {'content-type': 'application/json'}
622
        req_body = parse_request({str(action): ""}, self.logger)
623 624
        self._call_astakos(path, headers=req_headers,
                           body=req_body, method="POST")
625

626
    def accept_commission(self, serial):
627
        """Accept a commission (see commission_action)"""
628
        self.commission_action(serial, "accept")
629

630
    def reject_commission(self, serial):
631
        """Reject a commission (see commission_action)"""
632
        self.commission_action(serial, "reject")
633

634
    # ----------------------------------
635
    # do a POST to ``API_COMMISSIONS_ACTION``
636
    def resolve_commissions(self, accept_serials, reject_serials):
637 638 639 640 641 642 643 644 645 646 647
        """Resolve multiple commissions at once

        Keyword arguments:
        accept_serials  -- commissions to accept (list of ints)
        reject_serials  -- commissions to reject (list of ints)

        In case of success return a dict of dicts describing which
        commissions accepted, which rejected and which failed to
        resolved.

        """
648 649 650
        check_input("resolve_commissions", self.logger,
                    accept_serials=accept_serials,
                    reject_serials=reject_serials)
651 652 653 654 655

        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({"accept": accept_serials,
                                  "reject": reject_serials},
                                 self.logger)
656 657 658
        return self._call_astakos(self.api_commissions_action,
                                  headers=req_headers, body=req_body,
                                  method="POST")
659

660 661
    # ----------------------------
    # do a GET to ``API_PROJECTS``
662
    def get_projects(self, name=None, state=None, owner=None):
663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681
        """Retrieve all accessible projects

        Arguments:
        name  -- filter by name (optional)
        state -- filter by state (optional)
        owner -- filter by owner (optional)

        In case of success, return a list of project descriptions.
        """
        filters = {}
        if name is not None:
            filters["name"] = name
        if state is not None:
            filters["state"] = state
        if owner is not None:
            filters["owner"] = owner
        req_headers = {'content-type': 'application/json'}
        req_body = (parse_request({"filter": filters}, self.logger)
                    if filters else None)
682 683
        return self._call_astakos(self.api_projects,
                                  headers=req_headers, body=req_body)
684 685 686

    # -----------------------------------------
    # do a GET to ``API_PROJECTS``/<project_id>
687
    def get_project(self, project_id):
688 689 690 691 692 693 694
        """Retrieve project description, if accessible

        Arguments:
        project_id -- project identifier

        In case of success, return project description.
        """
695 696
        path = join_urls(self.api_projects, str(project_id))
        return self._call_astakos(path)
697 698 699

    # -----------------------------
    # do a POST to ``API_PROJECTS``
700
    def create_project(self, specs):
701 702 703 704 705 706 707 708 709
        """Submit application to create a new project

        Arguments:
        specs -- dict describing a project

        In case of success, return project and application identifiers.
        """
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request(specs, self.logger)
710 711 712
        return self._call_astakos(self.api_projects,
                                  headers=req_headers, body=req_body,
                                  method="POST")
713 714 715

    # ------------------------------------------
    # do a POST to ``API_PROJECTS``/<project_id>
716
    def modify_project(self, project_id, specs):
717 718 719 720 721 722 723 724
        """Submit application to modify an existing project

        Arguments:
        project_id -- project identifier
        specs      -- dict describing a project

        In case of success, return project and application identifiers.
        """
725
        path = join_urls(self.api_projects, str(project_id))
726 727
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request(specs, self.logger)
728 729
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
730 731 732

    # -------------------------------------------------
    # do a POST to ``API_PROJECTS``/<project_id>/action
733
    def project_action(self, project_id, action, reason=""):
734 735 736 737 738 739 740 741 742 743
        """Perform action on a project

        Arguments:
        project_id -- project identifier
        action     -- action to perform, one of "suspend", "unsuspend",
                      "terminate", "reinstate"
        reason     -- reason of performing the action

        In case of success, return nothing.
        """
744
        path = join_urls(self.api_projects, str(project_id))
745 746 747
        path = join_urls(path, "action")
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({action: reason}, self.logger)
748 749
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
750 751 752

    # --------------------------------
    # do a GET to ``API_APPLICATIONS``
753
    def get_applications(self, project=None):
754 755 756 757 758 759 760 761 762 763
        """Retrieve all accessible applications

        Arguments:
        project -- filter by project (optional)

        In case of success, return a list of application descriptions.
        """
        req_headers = {'content-type': 'application/json'}
        body = {"project": project} if project is not None else None
        req_body = parse_request(body, self.logger) if body else None
764 765
        return self._call_astakos(self.api_applications,
                                  headers=req_headers, body=req_body)
766 767 768

    # -----------------------------------------
    # do a GET to ``API_APPLICATIONS``/<app_id>
769
    def get_application(self, app_id):
770 771 772 773 774 775 776
        """Retrieve application description, if accessible

        Arguments:
        app_id -- application identifier

        In case of success, return application description.
        """
777 778
        path = join_urls(self.api_applications, str(app_id))
        return self._call_astakos(path)
779 780 781

    # -------------------------------------------------
    # do a POST to ``API_APPLICATIONS``/<app_id>/action
782
    def application_action(self, app_id, action, reason=""):
783 784 785 786 787 788 789 790 791 792
        """Perform action on an application

        Arguments:
        app_id -- application identifier
        action -- action to perform, one of "approve", "deny",
                  "dismiss", "cancel"
        reason -- reason of performing the action

        In case of success, return nothing.
        """
793
        path = join_urls(self.api_applications, str(app_id))
794 795 796
        path = join_urls(path, "action")
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({action: reason}, self.logger)
797 798
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
799 800 801

    # -------------------------------
    # do a GET to ``API_MEMBERSHIPS``
802
    def get_memberships(self, project=None):
803 804 805 806 807 808 809 810 811 812
        """Retrieve all accessible memberships

        Arguments:
        project -- filter by project (optional)

        In case of success, return a list of membership descriptions.
        """
        req_headers = {'content-type': 'application/json'}
        body = {"project": project} if project is not None else None
        req_body = parse_request(body, self.logger) if body else None
813 814
        return self._call_astakos(self.api_memberships,
                                  headers=req_headers, body=req_body)
815 816 817

    # -----------------------------------------
    # do a GET to ``API_MEMBERSHIPS``/<memb_id>
818
    def get_membership(self, memb_id):
819 820 821 822 823 824 825
        """Retrieve membership description, if accessible

        Arguments:
        memb_id -- membership identifier

        In case of success, return membership description.
        """
826 827
        path = join_urls(self.api_memberships, str(memb_id))
        return self._call_astakos(path)
828 829 830

    # -------------------------------------------------
    # do a POST to ``API_MEMBERSHIPS``/<memb_id>/action
831
    def membership_action(self, memb_id, action, reason=""):
832 833 834 835 836 837 838 839 840 841
        """Perform action on a membership

        Arguments:
        memb_id -- membership identifier
        action  -- action to perform, one of "leave", "cancel", "accept",
                   "reject", "remove"
        reason  -- reason of performing the action

        In case of success, return nothing.
        """
842
        path = join_urls(self.api_memberships, str(memb_id))
843 844 845
        path = join_urls(path, "action")
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({action: reason}, self.logger)
846 847
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
848 849 850

    # --------------------------------
    # do a POST to ``API_MEMBERSHIPS``
851
    def join_project(self, project_id):
852 853 854 855 856 857 858 859 860 861
        """Join a project

        Arguments:
        project_id -- project identifier

        In case of success, return membership identifier.
        """
        req_headers = {'content-type': 'application/json'}
        body = {"join": {"project": project_id}}
        req_body = parse_request(body, self.logger)
862 863
        return self._call_astakos(self.api_memberships, headers=req_headers,
                                  body=req_body, method="POST")
864 865 866

    # --------------------------------
    # do a POST to ``API_MEMBERSHIPS``
867
    def enroll_member(self, project_id, email):
868 869 870 871 872 873 874 875 876 877 878
        """Enroll a user in a project

        Arguments:
        project_id -- project identifier
        email      -- user identified by email

        In case of success, return membership identifier.
        """
        req_headers = {'content-type': 'application/json'}
        body = {"enroll": {"project": project_id, "user": email}}
        req_body = parse_request(body, self.logger)
879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895
        return self._call_astakos(self.api_memberships, headers=req_headers,
                                  body=req_body, method="POST")


# --------------------------------------------------------------------
# parse endpoints
def parse_endpoints(endpoints, ep_name=None, ep_type=None,
                    ep_region=None, ep_version_id=None):
    """Parse endpoints server response and extract the ones needed

    Keyword arguments:
    endpoints     -- the endpoints (json response from get_endpoints)
    ep_name       -- return only endpoints with this name (optional)
    ep_type       -- return only endpoints with this type (optional)
    ep_region     -- return only endpoints with this region (optional)
    ep_version_id -- return only endpoints with this versionId (optional)

896
    In case one of the `name', `type', `region', `version_id' parameters
897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931
    is given, return only the endpoints that match all of these criteria.
    If no match is found then raise NoEndpoints exception.

    """
    try:
        catalog = endpoints['access']['serviceCatalog']
        if ep_name is not None:
            catalog = \
                [c for c in catalog if c['name'] == ep_name]
        if ep_type is not None:
            catalog = \
                [c for c in catalog if c['type'] == ep_type]
        if ep_region is not None:
            for c in catalog:
                c['endpoints'] = [e for e in c['endpoints']
                                  if e['region'] == ep_region]
            # Remove catalog entries with no endpoints
            catalog = \
                [c for c in catalog if c['endpoints']]
        if ep_version_id is not None:
            for c in catalog:
                c['endpoints'] = [e for e in c['endpoints']
                                  if e['versionId'] == ep_version_id]
            # Remove catalog entries with no endpoints
            catalog = \
                [c for c in catalog if c['endpoints']]

        if not catalog:
            raise NoEndpoints(ep_name, ep_type,
                              ep_region, ep_version_id)
        else:
            return catalog
    except KeyError:
        raise NoEndpoints()

932

933 934
# --------------------------------------------------------------------
# Private functions
935
# We want _do_request to be a distinct function
936
# so that we can replace it during unit tests.
937
def _do_request(conn, method, url, **kwargs):
938 939 940 941 942 943
    """The actual request. This function can easily be mocked"""
    conn.request(method, url, **kwargs)
    response = conn.getresponse()
    length = response.getheader('content-length', None)
    data = response.read(length)
    status = int(response.status)
944 945
    message = response.reason
    return (message, data, status)