__init__.py 36 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 base64 import b64encode
43
from copy import copy
44 45

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


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

56
def get_token_from_cookie(request, cookie_name):
57 58 59 60 61 62 63 64 65 66
    """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]
67
    except BaseException:
68 69 70
        return None


71 72 73
# 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
74 75 76
    """AstakosClient Class Implementation"""

    # ----------------------------------
77 78 79 80 81 82
    # 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):
83
        """Initialize AstakosClient Class
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
84 85

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

        """
94 95

        # Get logger
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
96
        if logger is None:
97 98 99 100
            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
101
            logger = logging.getLogger("astakosclient")
102 103 104
        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
105

106 107
        # 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
108

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

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

126 127 128 129 130
        # 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")

131 132
    def _fill_endpoints(self, endpoints):
        astakos_service_catalog = parse_endpoints(
133
            endpoints, ep_name="astakos_account", ep_version_id="v1.0")
134 135 136 137 138 139 140 141 142 143 144 145 146 147
        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)

148 149 150 151 152 153
        oa2_service_catalog = parse_endpoints(endpoints, ep_name="astakos_oa2")
        self._oa2_url = \
            oa2_service_catalog[0]['endpoints'][0]['publicURL']
        parsed_oa2_url = urlparse.urlparse(self._oa2_url)
        self._oa2_prefix = parsed_oa2_url.path

154 155
    def _get_value(self, s):
        assert s in ['_account_url', '_account_prefix',
156 157
                     '_ui_url', '_ui_prefix',
                     '_oa2_url', '_oa2_prefix']
158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179
        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')

180 181 182 183 184 185 186 187
    @property
    def oa2_url(self):
        return self._get_value('_oa2_url')

    @property
    def oa2_prefix(self):
        return self._get_value('_oa2_prefix')

188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206
    @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")
207

208 209 210
    @property
    def api_commissions(self):
        return join_urls(self.account_prefix, "commissions")
211

212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234
    @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")
235

236 237 238 239 240 241 242 243
    @property
    def api_oa2_auth(self):
        return join_urls(self.oa2_prefix, "auth")

    @property
    def api_oa2_token(self):
        return join_urls(self.oa2_prefix, "token")

244
    # ----------------------------------
245 246
    @retry_dec
    def _call_astakos(self, request_path, headers=None,
247
                      body=None, method="GET", log_body=True):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
248
        """Make the actual call to Astakos Service"""
249 250
        hashed_token = hashlib.sha1()
        hashed_token.update(self.token)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
251
        self.logger.debug(
252 253 254 255
            "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
256

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
257
        # Check Input
258 259 260 261
        if headers is None:
            headers = {}
        if body is None:
            body = {}
262 263 264
        # Initialize log_request and log_response attributes
        self.log_request = None
        self.log_response = None
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
265

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
266 267
        # Build request's header and body
        kwargs = {}
268
        kwargs['headers'] = copy(headers)
269
        kwargs['headers']['X-Auth-Token'] = self.token
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
270
        if body:
271
            kwargs['body'] = copy(body)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
272 273 274 275 276 277
            kwargs['headers'].setdefault(
                'content-type', 'application/octet-stream')
        kwargs['headers'].setdefault('content-length',
                                     len(body) if body else 0)

        try:
278
            # Get the connection object
279
            with self.conn_class(self.astakos_base_url) as conn:
280 281 282 283 284
                # Log the request so other clients (like kamaki)
                # can use them to produce their own log messages.
                self.log_request = dict(method=method, path=request_path)
                self.log_request.update(kwargs)

285
                # Send request
286
                # Used * or ** magic. pylint: disable-msg=W0142
287
                (message, data, status) = \
288
                    _do_request(conn, method, request_path, **kwargs)
289 290 291 292 293

                # Log the response so other clients (like kamaki)
                # can use them to produce their own log messages.
                self.log_response = dict(
                    status=status, message=message, data=data)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
294
        except Exception as err:
295
            self.logger.error("Failed to send request: %s" % repr(err))
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
296 297 298 299
            raise AstakosClientException(str(err))

        # Return
        self.logger.debug("Request returned with status %s" % status)
300
        if status == 400:
301
            raise BadRequest(message, data)
302
        elif status == 401:
303
            raise Unauthorized(message, data)
304
        elif status == 403:
305
            raise Forbidden(message, data)
306
        elif status == 404:
307
            raise NotFound(message, data)
308
        elif status < 200 or status >= 300:
309
            raise AstakosClientException(message, data, status)
310 311 312 313 314

        try:
            if data:
                return simplejson.loads(unicode(data))
            else:
315
                return None
316
        except Exception as err:
317 318
            msg = "Cannot parse response \"%s\" with simplejson: %s"
            self.logger.error(msg % (data, str(err)))
319
            raise InvalidResponse(str(err), data)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
320 321

    # ----------------------------------
322
    # do a POST to ``API_USERCATALOGS`` (or ``API_SERVICE_USERCATALOGS``)
323
    #   with {'uuids': uuids}
324 325
    def _uuid_catalog(self, uuids, req_path):
        """Helper function to retrieve uuid catalog"""
326
        req_headers = {'content-type': 'application/json'}
327
        req_body = parse_request({'uuids': uuids}, self.logger)
328 329
        data = self._call_astakos(req_path, headers=req_headers,
                                  body=req_body, method="POST")
330 331 332
        if "uuid_catalog" in data:
            return data.get("uuid_catalog")
        else:
333 334 335 336
            msg = "_uuid_catalog request returned %s. No uuid_catalog found" \
                  % data
            self.logger.error(msg)
            raise AstakosClientException(msg)
337

338
    def get_usernames(self, uuids):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
339 340 341 342 343 344 345 346 347
        """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

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

350
    def get_username(self, uuid):
351
        """Return the user name of a uuid (see get_usernames)"""
352
        check_input("get_username", self.logger, uuid=uuid)
353
        uuid_dict = self.get_usernames([uuid])
354 355 356
        if uuid in uuid_dict:
            return uuid_dict.get(uuid)
        else:
357
            raise NoUserName(uuid)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
358

359
    def service_get_usernames(self, uuids):
360
        """Return a uuid_catalog dict using a service's token"""
361
        return self._uuid_catalog(uuids, self.api_service_usercatalogs)
362

363
    def service_get_username(self, uuid):
364
        """Return the displayName of a uuid using a service's token"""
365
        check_input("service_get_username", self.logger, uuid=uuid)
366
        uuid_dict = self.service_get_usernames([uuid])
367 368 369
        if uuid in uuid_dict:
            return uuid_dict.get(uuid)
        else:
370
            raise NoUserName(uuid)
371

372
    # ----------------------------------
373
    # do a POST to ``API_USERCATALOGS`` (or ``API_SERVICE_USERCATALOGS``)
374
    #   with {'displaynames': display_names}
375 376
    def _displayname_catalog(self, display_names, req_path):
        """Helper function to retrieve display names catalog"""
377
        req_headers = {'content-type': 'application/json'}
378
        req_body = parse_request({'displaynames': display_names}, self.logger)
379 380
        data = self._call_astakos(req_path, headers=req_headers,
                                  body=req_body, method="POST")
381 382 383
        if "displayname_catalog" in data:
            return data.get("displayname_catalog")
        else:
384 385 386 387
            msg = "_displayname_catalog request returned %s. " \
                  "No displayname_catalog found" % data
            self.logger.error(msg)
            raise AstakosClientException(msg)
388

389
    def get_uuids(self, display_names):
390 391 392 393 394 395 396 397 398
        """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

        """
399 400
        return self._displayname_catalog(
            display_names, self.api_usercatalogs)
401

402
    def get_uuid(self, display_name):
403
        """Return the uuid of a name (see getUUIDs)"""
404
        check_input("get_uuid", self.logger, display_name=display_name)
405
        name_dict = self.get_uuids([display_name])
406 407 408 409
        if display_name in name_dict:
            return name_dict.get(display_name)
        else:
            raise NoUUID(display_name)
410

411
    def service_get_uuids(self, display_names):
412
        """Return a display_name catalog using a service's token"""
413 414
        return self._displayname_catalog(
            display_names, self.api_service_usercatalogs)
415

416
    def service_get_uuid(self, display_name):
417
        """Return the uuid of a name using a service's token"""
418
        check_input("service_get_uuid", self.logger, display_name=display_name)
419
        name_dict = self.service_get_uuids([display_name])
420 421 422 423
        if display_name in name_dict:
            return name_dict.get(display_name)
        else:
            raise NoUUID(display_name)
424

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
425
    # ----------------------------------
426
    # do a GET to ``API_GETSERVICES``
427
    def get_services(self):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
428
        """Return a list of dicts with the registered services"""
429
        return self._call_astakos(self.api_getservices)
430 431

    # ----------------------------------
432
    # do a GET to ``API_RESOURCES``
433 434
    def get_resources(self):
        """Return a dict of dicts with the available resources"""
435
        return self._call_astakos(self.api_resources)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
436

437 438
    # ----------------------------------
    # do a POST to ``API_FEEDBACK``
439
    def send_feedback(self, message, data):
440 441 442 443 444 445 446 447 448 449 450 451 452
        """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})
453 454
        self._call_astakos(self.api_feedback, headers=None,
                           body=req_body, method="POST")
455

456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474
    # -----------------------------------------
    # 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):
475
        """ Authenticate and get services' endpoints
476 477

        Keyword arguments:
478
        tenant_name         -- user's uniq id (optional)
479 480

        It returns back the token as well as information about the token
481
        holder and the services he/she can access (in json format).
482 483 484 485

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

486 487 488 489
        In case of error raise an AstakosClientException.

        """
        req_headers = {'content-type': 'application/json'}
490 491 492 493 494 495 496 497 498
        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
499

500
    # ----------------------------------
501
    # do a GET to ``API_QUOTAS``
502
    def get_quotas(self):
503 504 505 506 507 508
        """Get user's quotas

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

        """
509
        return self._call_astakos(self.api_quotas)
510

511
    # ----------------------------------
512
    # do a GET to ``API_SERVICE_QUOTAS``
513
    def service_get_quotas(self, user=None):
514 515 516
        """Get all quotas for resources associated with the service

        Keyword arguments:
517
        user    -- optionally, the uuid of a specific user
518 519

        In case of success return a dict of dicts of dicts with current quotas
520
        for all users, or of a specified user, if user argument is set.
521 522 523
        Otherwise raise an AstakosClientException

        """
524
        query = self.api_service_quotas
525 526
        if user is not None:
            query += "?user=" + user
527
        return self._call_astakos(query)
528

529
    # ----------------------------------
530
    # do a POST to ``API_COMMISSIONS``
531
    def issue_commission(self, request):
532 533 534 535 536 537 538 539 540 541
        """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'}
542
        req_body = parse_request(request, self.logger)
543
        try:
544 545 546 547
            response = self._call_astakos(self.api_commissions,
                                          headers=req_headers,
                                          body=req_body,
                                          method="POST")
548 549 550 551 552 553 554 555 556
        except AstakosClientException as err:
            if err.status == 413:
                raise QuotaLimit(err.message, err.details)
            else:
                raise

        if "serial" in response:
            return response['serial']
        else:
557 558 559 560
            msg = "issue_commission_core request returned %s. " + \
                  "No serial found" % response
            self.logger.error(msg)
            raise AstakosClientException(msg)
561

562
    def issue_one_commission(self, holder, source, provisions,
563
                             name="", force=False, auto_accept=False):
564 565 566 567 568
        """Issue one commission (with specific holder and source)

        keyword arguments:
        holder      -- user's id (string)
        source      -- commission's source (ex system) (string)
569
        provisions  -- resources with their quantity (dict from string to int)
570
        name        -- description of the commission (string)
571 572 573 574 575 576 577 578
        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)

        """
579 580 581
        check_input("issue_one_commission", self.logger,
                    holder=holder, source=source,
                    provisions=provisions)
582 583 584 585

        request = {}
        request["force"] = force
        request["auto_accept"] = auto_accept
586
        request["name"] = name
587 588
        try:
            request["provisions"] = []
589
            for resource, quantity in provisions.iteritems():
590 591 592
                prov = {"holder": holder, "source": source,
                        "resource": resource, "quantity": quantity}
                request["provisions"].append(prov)
593 594 595 596
        except Exception as err:
            self.logger.error(str(err))
            raise BadValue(str(err))

597
        return self.issue_commission(request)
598

599
    # ----------------------------------
600
    # do a GET to ``API_COMMISSIONS``
601
    def get_pending_commissions(self):
602 603 604 605 606 607
        """Get Pending Commissions

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

        """
608
        return self._call_astakos(self.api_commissions)
609

610
    # ----------------------------------
611
    # do a GET to ``API_COMMISSIONS``/<serial>
612
    def get_commission_info(self, serial):
613 614 615 616 617 618
        """Get Description of a Commission

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

        In case of success return a dict of dicts containing
619
        informations (details) about the requested commission
620 621

        """
622
        check_input("get_commission_info", self.logger, serial=serial)
623

624 625
        path = self.api_commissions.rstrip('/') + "/" + str(serial)
        return self._call_astakos(path)
626

627
    # ----------------------------------
628
    # do a POST to ``API_COMMISSIONS``/<serial>/action"
629
    def commission_action(self, serial, action):
630
        """Perform a commission action
631 632 633 634 635 636 637 638

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

        In case of success return nothing.

        """
639 640
        check_input("commission_action", self.logger,
                    serial=serial, action=action)
641

642
        path = self.api_commissions.rstrip('/') + "/" + str(serial) + "/action"
643
        req_headers = {'content-type': 'application/json'}
644
        req_body = parse_request({str(action): ""}, self.logger)
645 646
        self._call_astakos(path, headers=req_headers,
                           body=req_body, method="POST")
647

648
    def accept_commission(self, serial):
649
        """Accept a commission (see commission_action)"""
650
        self.commission_action(serial, "accept")
651

652
    def reject_commission(self, serial):
653
        """Reject a commission (see commission_action)"""
654
        self.commission_action(serial, "reject")
655

656
    # ----------------------------------
657
    # do a POST to ``API_COMMISSIONS_ACTION``
658
    def resolve_commissions(self, accept_serials, reject_serials):
659 660 661 662 663 664 665 666 667 668 669
        """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.

        """
670 671 672
        check_input("resolve_commissions", self.logger,
                    accept_serials=accept_serials,
                    reject_serials=reject_serials)
673 674 675 676 677

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

682 683
    # ----------------------------
    # do a GET to ``API_PROJECTS``
684
    def get_projects(self, name=None, state=None, owner=None):
685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703
        """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)
704 705
        return self._call_astakos(self.api_projects,
                                  headers=req_headers, body=req_body)
706 707 708

    # -----------------------------------------
    # do a GET to ``API_PROJECTS``/<project_id>
709
    def get_project(self, project_id):
710 711 712 713 714 715 716
        """Retrieve project description, if accessible

        Arguments:
        project_id -- project identifier

        In case of success, return project description.
        """
717 718
        path = join_urls(self.api_projects, str(project_id))
        return self._call_astakos(path)
719 720 721

    # -----------------------------
    # do a POST to ``API_PROJECTS``
722
    def create_project(self, specs):
723 724 725 726 727 728 729 730 731
        """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)
732 733 734
        return self._call_astakos(self.api_projects,
                                  headers=req_headers, body=req_body,
                                  method="POST")
735 736 737

    # ------------------------------------------
    # do a POST to ``API_PROJECTS``/<project_id>
738
    def modify_project(self, project_id, specs):
739 740 741 742 743 744 745 746
        """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.
        """
747
        path = join_urls(self.api_projects, str(project_id))
748 749
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request(specs, self.logger)
750 751
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
752 753 754

    # -------------------------------------------------
    # do a POST to ``API_PROJECTS``/<project_id>/action
755
    def project_action(self, project_id, action, reason=""):
756 757 758 759 760 761 762 763 764 765
        """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.
        """
766
        path = join_urls(self.api_projects, str(project_id))
767 768 769
        path = join_urls(path, "action")
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({action: reason}, self.logger)
770 771
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
772 773 774

    # --------------------------------
    # do a GET to ``API_APPLICATIONS``
775
    def get_applications(self, project=None):
776 777 778 779 780 781 782 783 784 785
        """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
786 787
        return self._call_astakos(self.api_applications,
                                  headers=req_headers, body=req_body)
788 789 790

    # -----------------------------------------
    # do a GET to ``API_APPLICATIONS``/<app_id>
791
    def get_application(self, app_id):
792 793 794 795 796 797 798
        """Retrieve application description, if accessible

        Arguments:
        app_id -- application identifier

        In case of success, return application description.
        """
799 800
        path = join_urls(self.api_applications, str(app_id))
        return self._call_astakos(path)
801 802 803

    # -------------------------------------------------
    # do a POST to ``API_APPLICATIONS``/<app_id>/action
804
    def application_action(self, app_id, action, reason=""):
805 806 807 808 809 810 811 812 813 814
        """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.
        """
815
        path = join_urls(self.api_applications, str(app_id))
816 817 818
        path = join_urls(path, "action")
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({action: reason}, self.logger)
819 820
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
821 822 823

    # -------------------------------
    # do a GET to ``API_MEMBERSHIPS``
824
    def get_memberships(self, project=None):
825 826 827 828 829 830 831 832 833 834
        """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
835 836
        return self._call_astakos(self.api_memberships,
                                  headers=req_headers, body=req_body)
837 838 839

    # -----------------------------------------
    # do a GET to ``API_MEMBERSHIPS``/<memb_id>
840
    def get_membership(self, memb_id):
841 842 843 844 845 846 847
        """Retrieve membership description, if accessible

        Arguments:
        memb_id -- membership identifier

        In case of success, return membership description.
        """
848 849
        path = join_urls(self.api_memberships, str(memb_id))
        return self._call_astakos(path)
850 851 852

    # -------------------------------------------------
    # do a POST to ``API_MEMBERSHIPS``/<memb_id>/action
853
    def membership_action(self, memb_id, action, reason=""):
854 855 856 857 858 859 860 861 862 863
        """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.
        """
864
        path = join_urls(self.api_memberships, str(memb_id))
865 866 867
        path = join_urls(path, "action")
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({action: reason}, self.logger)
868 869
        return self._call_astakos(path, headers=req_headers,
                                  body=req_body, method="POST")
870 871 872

    # --------------------------------
    # do a POST to ``API_MEMBERSHIPS``
873
    def join_project(self, project_id):
874 875 876 877 878 879 880 881 882 883
        """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)
884 885
        return self._call_astakos(self.api_memberships, headers=req_headers,
                                  body=req_body, method="POST")
886 887 888

    # --------------------------------
    # do a POST to ``API_MEMBERSHIPS``
889
    def enroll_member(self, project_id, email):
890 891 892 893 894 895 896 897 898 899 900
        """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)
901 902 903
        return self._call_astakos(self.api_memberships, headers=req_headers,
                                  body=req_body, method="POST")

904 905 906 907 908 909 910 911 912 913 914 915
    # --------------------------------
    # do a POST to ``API_OA2_TOKEN``
    def get_token(self, grant_type, client_id, client_secret, **body_params):
        headers = {'Content-Type': 'application/x-www-form-urlencoded',
                   'Authorization': 'Basic %s' % b64encode('%s:%s' %
                                                           (client_id,
                                                            client_secret))}
        body_params['grant_type'] = grant_type
        body = urllib.urlencode(body_params)
        return self._call_astakos(self.api_oa2_token, headers=headers,
                                  body=body, method="POST")

916 917 918 919 920 921 922 923 924 925 926 927 928 929

# --------------------------------------------------------------------
# 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)

930
    In case one of the `name', `type', `region', `version_id' parameters
931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965
    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()

966

967 968
# --------------------------------------------------------------------
# Private functions
969
# We want _do_request to be a distinct function
970
# so that we can replace it during unit tests.
971
def _do_request(conn, method, url, **kwargs):
972 973 974 975 976 977
    """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)
978 979
    message = response.reason
    return (message, data, status)