Statistics
| Branch: | Tag: | Revision:

root / astakosclient / astakosclient / __init__.py @ d2104099

History | View | Annotate | Download (36 kB)

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

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

    
38
import logging
39
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
# --------------------------------------------------------------------
54
# Astakos Client Class
55

    
56
def get_token_from_cookie(request, cookie_name):
57
    """Extract token from the cookie name provided
58

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

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

    
70

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

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

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

93
        """
94

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

    
106
        # Check that token and auth_url (mandatory options) are given
107
        check_input("__init__", logger, token=token, auth_url=auth_url)
108

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

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

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

    
131
    def _fill_endpoints(self, endpoints):
132
        astakos_service_catalog = parse_endpoints(
133
            endpoints, ep_name="astakos_account", ep_version_id="v1.0")
134
        self._account_url = \
135
            astakos_service_catalog[0]['endpoints'][0]['publicURL']
136
        parsed_account_url = urlparse.urlparse(self._account_url)
137

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

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

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

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

    
154
    def _get_value(self, s):
155
        assert s in ['_account_url', '_account_prefix',
156
                     '_ui_url', '_ui_prefix',
157
                     '_oa2_url', '_oa2_prefix']
158
        try:
159
            return getattr(self, s)
160
        except AttributeError:
161
            self.get_endpoints()
162
            return getattr(self, s)
163

    
164
    @property
165
    def account_url(self):
166
        return self._get_value('_account_url')
167

    
168
    @property
169
    def account_prefix(self):
170
        return self._get_value('_account_prefix')
171

    
172
    @property
173
    def ui_url(self):
174
        return self._get_value('_ui_url')
175

    
176
    @property
177
    def ui_prefix(self):
178
        return self._get_value('_ui_prefix')
179

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

    
184
    @property
185
    def oa2_prefix(self):
186
        return self._get_value('_oa2_prefix')
187

    
188
    @property
189
    def api_usercatalogs(self):
190
        return join_urls(self.account_prefix, "user_catalogs")
191

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

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

    
200
    @property
201
    def api_quotas(self):
202
        return join_urls(self.account_prefix, "quotas")
203

    
204
    @property
205
    def api_service_quotas(self):
206
        return join_urls(self.account_prefix, "service_quotas")
207

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

    
212
    @property
213
    def api_commissions_action(self):
214
        return join_urls(self.api_commissions, "action")
215

    
216
    @property
217
    def api_feedback(self):
218
        return join_urls(self.account_prefix, "feedback")
219

    
220
    @property
221
    def api_projects(self):
222
        return join_urls(self.account_prefix, "projects")
223

    
224
    @property
225
    def api_applications(self):
226
        return join_urls(self.api_projects, "apps")
227

    
228
    @property
229
    def api_memberships(self):
230
        return join_urls(self.api_projects, "memberships")
231

    
232
    @property
233
    def api_getservices(self):
234
        return join_urls(self.ui_prefix, "get_services")
235

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

    
240
    @property
241
    def api_oa2_token(self):
242
        return join_urls(self.oa2_prefix, "token")
243

    
244
    # ----------------------------------
245
    @retry_dec
246
    def _call_astakos(self, request_path, headers=None,
247
                      body=None, method="GET", log_body=True):
248
        """Make the actual call to Astakos Service"""
249
        hashed_token = hashlib.sha1()
250
        hashed_token.update(self.token)
251
        self.logger.debug(
252
            "Make a %s request to %s, using token with hash %s, "
253
            "with headers %s and body %s",
254
            method, request_path, hashed_token.hexdigest(), headers,
255
            body if log_body else "(not logged)")
256

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

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

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

    
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
                # Log the response so other clients (like kamaki)
291
                # can use them to produce their own log messages.
292
                self.log_response = dict(
293
                    status=status, message=message, data=data)
294
        except Exception as err:
295
            self.logger.error("Failed to send request: %s" % repr(err))
296
            raise AstakosClientException(str(err))
297

    
298
        # Return
299
        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
        try:
312
            if data:
313
                return simplejson.loads(unicode(data))
314
            else:
315
                return None
316
        except Exception as err:
317
            msg = "Cannot parse response \"%s\" with simplejson: %s"
318
            self.logger.error(msg % (data, str(err)))
319
            raise InvalidResponse(str(err), data)
320

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

    
338
    def get_usernames(self, uuids):
339
        """Return a uuid_catalog dictionary for the given uuids
340

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

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

347
        """
348
        return self._uuid_catalog(uuids, self.api_usercatalogs)
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
        if uuid in uuid_dict:
355
            return uuid_dict.get(uuid)
356
        else:
357
            raise NoUserName(uuid)
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
        if uuid in uuid_dict:
368
            return uuid_dict.get(uuid)
369
        else:
370
            raise NoUserName(uuid)
371

    
372
    # ----------------------------------
373
    # do a POST to ``API_USERCATALOGS`` (or ``API_SERVICE_USERCATALOGS``)
374
    #   with {'displaynames': display_names}
375
    def _displayname_catalog(self, display_names, req_path):
376
        """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
        data = self._call_astakos(req_path, headers=req_headers,
380
                                  body=req_body, method="POST")
381
        if "displayname_catalog" in data:
382
            return data.get("displayname_catalog")
383
        else:
384
            msg = "_displayname_catalog request returned %s. " \
385
                  "No displayname_catalog found" % data
386
            self.logger.error(msg)
387
            raise AstakosClientException(msg)
388

    
389
    def get_uuids(self, display_names):
390
        """Return a displayname_catalog for the given names
391

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

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

398
        """
399
        return self._displayname_catalog(
400
            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
        if display_name in name_dict:
407
            return name_dict.get(display_name)
408
        else:
409
            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
        return self._displayname_catalog(
414
            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
        if display_name in name_dict:
421
            return name_dict.get(display_name)
422
        else:
423
            raise NoUUID(display_name)
424

    
425
    # ----------------------------------
426
    # do a GET to ``API_GETSERVICES``
427
    def get_services(self):
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
    def get_resources(self):
434
        """Return a dict of dicts with the available resources"""
435
        return self._call_astakos(self.api_resources)
436

    
437
    # ----------------------------------
438
    # do a POST to ``API_FEEDBACK``
439
    def send_feedback(self, message, data):
440
        """Send feedback to astakos service
441

442
        keyword arguments:
443
        message     -- Feedback message
444
        data        -- Additional information about service client status
445

446
        In case of success return nothing.
447
        Otherwise raise an AstakosClientException
448

449
        """
450
        check_input("send_feedback", self.logger, message=message, data=data)
451
        req_body = urllib.urlencode(
452
            {'feedback_msg': message, 'feedback_data': data})
453
        self._call_astakos(self.api_feedback, headers=None,
454
                           body=req_body, method="POST")
455

    
456
    # -----------------------------------------
457
    # do a POST to ``API_TOKENS`` with no token
458
    def get_endpoints(self):
459
        """ Get services' endpoints
460

461
        In case of error raise an AstakosClientException.
462

463
        """
464
        req_headers = {'content-type': 'application/json'}
465
        req_body = None
466
        r = self._call_astakos(self.api_tokens, headers=req_headers,
467
                               body=req_body, method="POST",
468
                               log_body=False)
469
        self._fill_endpoints(r)
470
        return r
471

    
472
    # --------------------------------------
473
    # do a POST to ``API_TOKENS`` with a token
474
    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
        The tenant_name is optional and if it is given it must match the
484
        user's uuid.
485

486
        In case of error raise an AstakosClientException.
487

488
        """
489
        req_headers = {'content-type': 'application/json'}
490
        body = {'auth': {'token': {'id': self.token}}}
491
        if tenant_name is not None:
492
            body['auth']['tenantName'] = tenant_name
493
        req_body = parse_request(body, self.logger)
494
        r = self._call_astakos(self.api_tokens, headers=req_headers,
495
                               body=req_body, method="POST",
496
                               log_body=False)
497
        self._fill_endpoints(r)
498
        return r
499

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

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

508
        """
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
        """Get all quotas for resources associated with the service
515

516
        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
        Otherwise raise an AstakosClientException
522

523
        """
524
        query = self.api_service_quotas
525
        if user is not None:
526
            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
        """Issue a commission
533

534
        Keyword arguments:
535
        request -- commision request (dict)
536

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

540
        """
541
        req_headers = {'content-type': 'application/json'}
542
        req_body = parse_request(request, self.logger)
543
        try:
544
            response = self._call_astakos(self.api_commissions,
545
                                          headers=req_headers,
546
                                          body=req_body,
547
                                          method="POST")
548
        except AstakosClientException as err:
549
            if err.status == 413:
550
                raise QuotaLimit(err.message, err.details)
551
            else:
552
                raise
553

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

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

566
        keyword arguments:
567
        holder      -- user's id (string)
568
        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
        force       -- force this commission (boolean)
572
        auto_accept -- auto accept this commission (boolean)
573

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

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

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

    
597
        return self.issue_commission(request)
598

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

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

607
        """
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
        """Get Description of a Commission
614

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

618
        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
        path = self.api_commissions.rstrip('/') + "/" + str(serial)
625
        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
        Keyword arguments:
633
        serial  -- commission's id (int)
634
        action  -- action to perform, currently accept/reject (string)
635

636
        In case of success return nothing.
637

638
        """
639
        check_input("commission_action", self.logger,
640
                    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
        self._call_astakos(path, headers=req_headers,
646
                           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
        """Resolve multiple commissions at once
660

661
        Keyword arguments:
662
        accept_serials  -- commissions to accept (list of ints)
663
        reject_serials  -- commissions to reject (list of ints)
664

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

669
        """
670
        check_input("resolve_commissions", self.logger,
671
                    accept_serials=accept_serials,
672
                    reject_serials=reject_serials)
673

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

    
682
    # ----------------------------
683
    # do a GET to ``API_PROJECTS``
684
    def get_projects(self, name=None, state=None, owner=None):
685
        """Retrieve all accessible projects
686

687
        Arguments:
688
        name  -- filter by name (optional)
689
        state -- filter by state (optional)
690
        owner -- filter by owner (optional)
691

692
        In case of success, return a list of project descriptions.
693
        """
694
        filters = {}
695
        if name is not None:
696
            filters["name"] = name
697
        if state is not None:
698
            filters["state"] = state
699
        if owner is not None:
700
            filters["owner"] = owner
701
        req_headers = {'content-type': 'application/json'}
702
        req_body = (parse_request({"filter": filters}, self.logger)
703
                    if filters else None)
704
        return self._call_astakos(self.api_projects,
705
                                  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
        """Retrieve project description, if accessible
711

712
        Arguments:
713
        project_id -- project identifier
714

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

    
720
    # -----------------------------
721
    # do a POST to ``API_PROJECTS``
722
    def create_project(self, specs):
723
        """Submit application to create a new project
724

725
        Arguments:
726
        specs -- dict describing a project
727

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

    
736
    # ------------------------------------------
737
    # do a POST to ``API_PROJECTS``/<project_id>
738
    def modify_project(self, project_id, specs):
739
        """Submit application to modify an existing project
740

741
        Arguments:
742
        project_id -- project identifier
743
        specs      -- dict describing a project
744

745
        In case of success, return project and application identifiers.
746
        """
747
        path = join_urls(self.api_projects, str(project_id))
748
        req_headers = {'content-type': 'application/json'}
749
        req_body = parse_request(specs, self.logger)
750
        return self._call_astakos(path, headers=req_headers,
751
                                  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
        """Perform action on a project
757

758
        Arguments:
759
        project_id -- project identifier
760
        action     -- action to perform, one of "suspend", "unsuspend",
761
                      "terminate", "reinstate"
762
        reason     -- reason of performing the action
763

764
        In case of success, return nothing.
765
        """
766
        path = join_urls(self.api_projects, str(project_id))
767
        path = join_urls(path, "action")
768
        req_headers = {'content-type': 'application/json'}
769
        req_body = parse_request({action: reason}, self.logger)
770
        return self._call_astakos(path, headers=req_headers,
771
                                  body=req_body, method="POST")
772

    
773
    # --------------------------------
774
    # do a GET to ``API_APPLICATIONS``
775
    def get_applications(self, project=None):
776
        """Retrieve all accessible applications
777

778
        Arguments:
779
        project -- filter by project (optional)
780

781
        In case of success, return a list of application descriptions.
782
        """
783
        req_headers = {'content-type': 'application/json'}
784
        body = {"project": project} if project is not None else None
785
        req_body = parse_request(body, self.logger) if body else None
786
        return self._call_astakos(self.api_applications,
787
                                  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
        """Retrieve application description, if accessible
793

794
        Arguments:
795
        app_id -- application identifier
796

797
        In case of success, return application description.
798
        """
799
        path = join_urls(self.api_applications, str(app_id))
800
        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
        """Perform action on an application
806

807
        Arguments:
808
        app_id -- application identifier
809
        action -- action to perform, one of "approve", "deny",
810
                  "dismiss", "cancel"
811
        reason -- reason of performing the action
812

813
        In case of success, return nothing.
814
        """
815
        path = join_urls(self.api_applications, str(app_id))
816
        path = join_urls(path, "action")
817
        req_headers = {'content-type': 'application/json'}
818
        req_body = parse_request({action: reason}, self.logger)
819
        return self._call_astakos(path, headers=req_headers,
820
                                  body=req_body, method="POST")
821

    
822
    # -------------------------------
823
    # do a GET to ``API_MEMBERSHIPS``
824
    def get_memberships(self, project=None):
825
        """Retrieve all accessible memberships
826

827
        Arguments:
828
        project -- filter by project (optional)
829

830
        In case of success, return a list of membership descriptions.
831
        """
832
        req_headers = {'content-type': 'application/json'}
833
        body = {"project": project} if project is not None else None
834
        req_body = parse_request(body, self.logger) if body else None
835
        return self._call_astakos(self.api_memberships,
836
                                  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
        """Retrieve membership description, if accessible
842

843
        Arguments:
844
        memb_id -- membership identifier
845

846
        In case of success, return membership description.
847
        """
848
        path = join_urls(self.api_memberships, str(memb_id))
849
        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
        """Perform action on a membership
855

856
        Arguments:
857
        memb_id -- membership identifier
858
        action  -- action to perform, one of "leave", "cancel", "accept",
859
                   "reject", "remove"
860
        reason  -- reason of performing the action
861

862
        In case of success, return nothing.
863
        """
864
        path = join_urls(self.api_memberships, str(memb_id))
865
        path = join_urls(path, "action")
866
        req_headers = {'content-type': 'application/json'}
867
        req_body = parse_request({action: reason}, self.logger)
868
        return self._call_astakos(path, headers=req_headers,
869
                                  body=req_body, method="POST")
870

    
871
    # --------------------------------
872
    # do a POST to ``API_MEMBERSHIPS``
873
    def join_project(self, project_id):
874
        """Join a project
875

876
        Arguments:
877
        project_id -- project identifier
878

879
        In case of success, return membership identifier.
880
        """
881
        req_headers = {'content-type': 'application/json'}
882
        body = {"join": {"project": project_id}}
883
        req_body = parse_request(body, self.logger)
884
        return self._call_astakos(self.api_memberships, headers=req_headers,
885
                                  body=req_body, method="POST")
886

    
887
    # --------------------------------
888
    # do a POST to ``API_MEMBERSHIPS``
889
    def enroll_member(self, project_id, email):
890
        """Enroll a user in a project
891

892
        Arguments:
893
        project_id -- project identifier
894
        email      -- user identified by email
895

896
        In case of success, return membership identifier.
897
        """
898
        req_headers = {'content-type': 'application/json'}
899
        body = {"enroll": {"project": project_id, "user": email}}
900
        req_body = parse_request(body, self.logger)
901
        return self._call_astakos(self.api_memberships, headers=req_headers,
902
                                  body=req_body, method="POST")
903

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

    
916

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

923
    Keyword arguments:
924
    endpoints     -- the endpoints (json response from get_endpoints)
925
    ep_name       -- return only endpoints with this name (optional)
926
    ep_type       -- return only endpoints with this type (optional)
927
    ep_region     -- return only endpoints with this region (optional)
928
    ep_version_id -- return only endpoints with this versionId (optional)
929

930
    In case one of the `name', `type', `region', `version_id' parameters
931
    is given, return only the endpoints that match all of these criteria.
932
    If no match is found then raise NoEndpoints exception.
933

934
    """
935
    try:
936
        catalog = endpoints['access']['serviceCatalog']
937
        if ep_name is not None:
938
            catalog = \
939
                [c for c in catalog if c['name'] == ep_name]
940
        if ep_type is not None:
941
            catalog = \
942
                [c for c in catalog if c['type'] == ep_type]
943
        if ep_region is not None:
944
            for c in catalog:
945
                c['endpoints'] = [e for e in c['endpoints']
946
                                  if e['region'] == ep_region]
947
            # Remove catalog entries with no endpoints
948
            catalog = \
949
                [c for c in catalog if c['endpoints']]
950
        if ep_version_id is not None:
951
            for c in catalog:
952
                c['endpoints'] = [e for e in c['endpoints']
953
                                  if e['versionId'] == ep_version_id]
954
            # Remove catalog entries with no endpoints
955
            catalog = \
956
                [c for c in catalog if c['endpoints']]
957

    
958
        if not catalog:
959
            raise NoEndpoints(ep_name, ep_type,
960
                              ep_region, ep_version_id)
961
        else:
962
            return catalog
963
    except KeyError:
964
        raise NoEndpoints()
965

    
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
    """The actual request. This function can easily be mocked"""
973
    conn.request(method, url, **kwargs)
974
    response = conn.getresponse()
975
    length = response.getheader('content-length', None)
976
    data = response.read(length)
977
    status = int(response.status)
978
    message = response.reason
979
    return (message, data, status)