Statistics
| Branch: | Tag: | Revision:

root / astakosclient / astakosclient / __init__.py @ 47bb45c0

History | View | Annotate | Download (30.7 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
import logging
35
import urlparse
36
import urllib
37
import hashlib
38
from copy import copy
39

    
40
import simplejson
41
from astakosclient.utils import \
42
    retry, scheme_to_class, parse_request, check_input
43
from astakosclient.errors import \
44
    AstakosClientException, Unauthorized, BadRequest, NotFound, Forbidden, \
45
    NoUserName, NoUUID, BadValue, QuotaLimit, InvalidResponse
46
from .keypath import get_path
47
from .services import astakos_services
48

    
49

    
50
# Customize astakos_services here?
51

    
52

    
53
def join_urls(a, b):
54
    """join_urls from synnefo.lib"""
55
    return a.rstrip("/") + "/" + b.lstrip("/")
56

    
57
# --------------------------------------------------------------------
58
# Astakos API urls
59
UI_PREFIX = get_path(astakos_services, 'astakos_ui.prefix')
60
ACCOUNTS_PREFIX = get_path(astakos_services, 'astakos_account.prefix')
61
ACCOUNTS_PREFIX = join_urls(ACCOUNTS_PREFIX, 'v1.0')
62
API_AUTHENTICATE = join_urls(ACCOUNTS_PREFIX, "authenticate")
63
API_USERCATALOGS = join_urls(ACCOUNTS_PREFIX, "user_catalogs")
64
API_SERVICE_USERCATALOGS = join_urls(ACCOUNTS_PREFIX, "service/user_catalogs")
65
API_GETSERVICES = join_urls(UI_PREFIX, "get_services")
66
API_RESOURCES = join_urls(ACCOUNTS_PREFIX, "resources")
67
API_QUOTAS = join_urls(ACCOUNTS_PREFIX, "quotas")
68
API_SERVICE_QUOTAS = join_urls(ACCOUNTS_PREFIX, "service_quotas")
69
API_COMMISSIONS = join_urls(ACCOUNTS_PREFIX, "commissions")
70
API_COMMISSIONS_ACTION = join_urls(API_COMMISSIONS, "action")
71
API_FEEDBACK = join_urls(ACCOUNTS_PREFIX, "feedback")
72
API_PROJECTS = join_urls(ACCOUNTS_PREFIX, "projects")
73
API_APPLICATIONS = join_urls(API_PROJECTS, "apps")
74
API_MEMBERSHIPS = join_urls(API_PROJECTS, "memberships")
75

    
76
# --------------------------------------------------------------------
77
# Astakos Keystone API urls
78
IDENTITY_PREFIX = get_path(astakos_services, 'astakos_identity.prefix')
79
IDENTITY_PREFIX = join_urls(IDENTITY_PREFIX, "v2.0")
80
API_TOKENS = join_urls(IDENTITY_PREFIX, "tokens")
81

    
82

    
83
# --------------------------------------------------------------------
84
# Astakos Client Class
85

    
86
def get_token_from_cookie(request, cookie_name):
87
    """Extract token from the cookie name provided
88

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

93
    """
94
    try:
95
        cookie_content = urllib.unquote(request.COOKIE.get(cookie_name, None))
96
        return cookie_content.split("|")[1]
97
    except:
98
        return None
99

    
100

    
101
class AstakosClient():
102
    """AstakosClient Class Implementation"""
103

    
104
    # ----------------------------------
105
    def __init__(self, astakos_url, retry=0,
106
                 use_pool=False, pool_size=8, logger=None):
107
        """Initialize AstakosClient Class
108

109
        Keyword arguments:
110
        astakos_url -- i.e https://accounts.example.com (string)
111
        use_pool    -- use objpool for http requests (boolean)
112
        retry       -- how many time to retry (integer)
113
        logger      -- pass a different logger
114

115
        """
116
        if logger is None:
117
            logging.basicConfig(
118
                format='%(asctime)s [%(levelname)s] %(name)s %(message)s',
119
                datefmt='%Y-%m-%d %H:%M:%S',
120
                level=logging.INFO)
121
            logger = logging.getLogger("astakosclient")
122
        logger.debug("Intialize AstakosClient: astakos_url = %s, "
123
                     "use_pool = %s" % (astakos_url, use_pool))
124

    
125
        check_input("__init__", logger, astakos_url=astakos_url)
126

    
127
        # Check for supported scheme
128
        p = urlparse.urlparse(astakos_url)
129
        conn_class = scheme_to_class(p.scheme, use_pool, pool_size)
130
        if conn_class is None:
131
            m = "Unsupported scheme: %s" % p.scheme
132
            logger.error(m)
133
            raise BadValue(m)
134

    
135
        # Save astakos_url etc. in our class
136
        self.retry = retry
137
        self.logger = logger
138
        self.netloc = p.netloc
139
        self.scheme = p.scheme
140
        self.path = p.path.rstrip('/')
141
        self.conn_class = conn_class
142

    
143
    # ----------------------------------
144
    @retry
145
    def _call_astakos(self, token, request_path, headers=None,
146
                      body=None, method="GET", log_body=True):
147
        """Make the actual call to Astakos Service"""
148
        if token is not None:
149
            hashed_token = hashlib.sha1()
150
            hashed_token.update(token)
151
            using_token = "using token %s" % (hashed_token.hexdigest())
152
        else:
153
            using_token = "without using token"
154
        self.logger.debug(
155
            "Make a %s request to %s %s with headers %s and body %s"
156
            % (method, request_path, using_token, headers,
157
               body if log_body else "(not logged)"))
158

    
159
        # Check Input
160
        if headers is None:
161
            headers = {}
162
        if body is None:
163
            body = {}
164
        path = self.path + "/" + request_path.strip('/')
165

    
166
        # Build request's header and body
167
        kwargs = {}
168
        kwargs['headers'] = copy(headers)
169
        if token is not None:
170
            kwargs['headers']['X-Auth-Token'] = token
171
        if body:
172
            kwargs['body'] = copy(body)
173
            kwargs['headers'].setdefault(
174
                'content-type', 'application/octet-stream')
175
        kwargs['headers'].setdefault('content-length',
176
                                     len(body) if body else 0)
177

    
178
        try:
179
            # Get the connection object
180
            with self.conn_class(self.netloc) as conn:
181
                # Send request
182
                (message, data, status) = \
183
                    _do_request(conn, method, path, **kwargs)
184
        except Exception as err:
185
            self.logger.error("Failed to send request: %s" % repr(err))
186
            raise AstakosClientException(str(err))
187

    
188
        # Return
189
        self.logger.debug("Request returned with status %s" % status)
190
        if status == 400:
191
            raise BadRequest(message, data)
192
        elif status == 401:
193
            raise Unauthorized(message, data)
194
        elif status == 403:
195
            raise Forbidden(message, data)
196
        elif status == 404:
197
            raise NotFound(message, data)
198
        elif status < 200 or status >= 300:
199
            raise AstakosClientException(message, data, status)
200

    
201
        try:
202
            if data:
203
                return simplejson.loads(unicode(data))
204
            else:
205
                return None
206
        except Exception as err:
207
            msg = "Cannot parse response \"%s\" with simplejson: %s"
208
            self.logger.error(msg % (data, str(err)))
209
            raise InvalidResponse(str(err), data)
210

    
211
    # ------------------------
212
    # do a GET to ``API_AUTHENTICATE``
213
    def get_user_info(self, token, usage=False):
214
        """Authenticate user and get user's info as a dictionary
215

216
        Keyword arguments:
217
        token   -- user's token (string)
218
        usage   -- return usage information for user (boolean)
219

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

223
        """
224
        # Send request
225
        auth_path = copy(API_AUTHENTICATE)
226
        if usage:
227
            auth_path += "?usage=1"
228
        return self._call_astakos(token, auth_path)
229

    
230
    # ----------------------------------
231
    # do a POST to ``API_USERCATALOGS`` (or ``API_SERVICE_USERCATALOGS``)
232
    #   with {'uuids': uuids}
233
    def _uuid_catalog(self, token, uuids, req_path):
234
        req_headers = {'content-type': 'application/json'}
235
        req_body = parse_request({'uuids': uuids}, self.logger)
236
        data = self._call_astakos(
237
            token, req_path, req_headers, req_body, "POST")
238
        if "uuid_catalog" in data:
239
            return data.get("uuid_catalog")
240
        else:
241
            m = "_uuid_catalog request returned %s. No uuid_catalog found" \
242
                % data
243
            self.logger.error(m)
244
            raise AstakosClientException(m)
245

    
246
    def get_usernames(self, token, uuids):
247
        """Return a uuid_catalog dictionary for the given uuids
248

249
        Keyword arguments:
250
        token   -- user's token (string)
251
        uuids   -- list of user ids (list of strings)
252

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

256
        """
257
        req_path = copy(API_USERCATALOGS)
258
        return self._uuid_catalog(token, uuids, req_path)
259

    
260
    def get_username(self, token, uuid):
261
        """Return the user name of a uuid (see get_usernames)"""
262
        check_input("get_username", self.logger, uuid=uuid)
263
        uuid_dict = self.get_usernames(token, [uuid])
264
        if uuid in uuid_dict:
265
            return uuid_dict.get(uuid)
266
        else:
267
            raise NoUserName(uuid)
268

    
269
    def service_get_usernames(self, token, uuids):
270
        """Return a uuid_catalog dict using a service's token"""
271
        req_path = copy(API_SERVICE_USERCATALOGS)
272
        return self._uuid_catalog(token, uuids, req_path)
273

    
274
    def service_get_username(self, token, uuid):
275
        """Return the displayName of a uuid using a service's token"""
276
        check_input("service_get_username", self.logger, uuid=uuid)
277
        uuid_dict = self.service_get_usernames(token, [uuid])
278
        if uuid in uuid_dict:
279
            return uuid_dict.get(uuid)
280
        else:
281
            raise NoUserName(uuid)
282

    
283
    # ----------------------------------
284
    # do a POST to ``API_USERCATALOGS`` (or ``API_SERVICE_USERCATALOGS``)
285
    #   with {'displaynames': display_names}
286
    def _displayname_catalog(self, token, display_names, req_path):
287
        req_headers = {'content-type': 'application/json'}
288
        req_body = parse_request({'displaynames': display_names}, self.logger)
289
        data = self._call_astakos(
290
            token, req_path, req_headers, req_body, "POST")
291
        if "displayname_catalog" in data:
292
            return data.get("displayname_catalog")
293
        else:
294
            m = "_displayname_catalog request returned %s. " \
295
                "No displayname_catalog found" % data
296
            self.logger.error(m)
297
            raise AstakosClientException(m)
298

    
299
    def get_uuids(self, token, display_names):
300
        """Return a displayname_catalog for the given names
301

302
        Keyword arguments:
303
        token           -- user's token (string)
304
        display_names   -- list of user names (list of strings)
305

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

309
        """
310
        req_path = copy(API_USERCATALOGS)
311
        return self._displayname_catalog(token, display_names, req_path)
312

    
313
    def get_uuid(self, token, display_name):
314
        """Return the uuid of a name (see getUUIDs)"""
315
        check_input("get_uuid", self.logger, display_name=display_name)
316
        name_dict = self.get_uuids(token, [display_name])
317
        if display_name in name_dict:
318
            return name_dict.get(display_name)
319
        else:
320
            raise NoUUID(display_name)
321

    
322
    def service_get_uuids(self, token, display_names):
323
        """Return a display_name catalog using a service's token"""
324
        req_path = copy(API_SERVICE_USERCATALOGS)
325
        return self._displayname_catalog(token, display_names, req_path)
326

    
327
    def service_get_uuid(self, token, display_name):
328
        """Return the uuid of a name using a service's token"""
329
        check_input("service_get_uuid", self.logger, display_name=display_name)
330
        name_dict = self.service_get_uuids(token, [display_name])
331
        if display_name in name_dict:
332
            return name_dict.get(display_name)
333
        else:
334
            raise NoUUID(display_name)
335

    
336
    # ----------------------------------
337
    # do a GET to ``API_GETSERVICES``
338
    def get_services(self):
339
        """Return a list of dicts with the registered services"""
340
        return self._call_astakos(None, copy(API_GETSERVICES))
341

    
342
    # ----------------------------------
343
    # do a GET to ``API_RESOURCES``
344
    def get_resources(self):
345
        """Return a dict of dicts with the available resources"""
346
        return self._call_astakos(None, copy(API_RESOURCES))
347

    
348
    # ----------------------------------
349
    # do a POST to ``API_FEEDBACK``
350
    def send_feedback(self, token, message, data):
351
        """Send feedback to astakos service
352

353
        keyword arguments:
354
        token       -- user's token (string)
355
        message     -- Feedback message
356
        data        -- Additional information about service client status
357

358
        In case of success return nothing.
359
        Otherwise raise an AstakosClientException
360

361
        """
362
        check_input("send_feedback", self.logger, message=message, data=data)
363
        path = copy(API_FEEDBACK)
364
        req_body = urllib.urlencode(
365
            {'feedback_msg': message, 'feedback_data': data})
366
        self._call_astakos(token, path, None, req_body, "POST")
367

    
368
    # ----------------------------------
369
    # do a POST to ``API_TOKENS``
370
    def get_endpoints(self, token, uuid=None):
371
        """ Fallback call for authenticate
372

373
        Keyword arguments:
374
        token   -- user's token (string)
375
        uuid    -- user's uniq id
376

377
        It returns back the token as well as information about the token
378
        holder and the services he/she can acess (in json format).
379
        In case of error raise an AstakosClientException.
380

381
        """
382
        req_path = copy(API_TOKENS)
383
        req_headers = {'content-type': 'application/json'}
384
        body = {'auth': {'token': {'id': token}}}
385
        if uuid is not None:
386
            body['auth']['tenantName'] = uuid
387
        req_body = parse_request(body, self.logger)
388
        return self._call_astakos(token, req_path, req_headers,
389
                                  req_body, "POST", False)
390

    
391
    # ----------------------------------
392
    # do a GET to ``API_QUOTAS``
393
    def get_quotas(self, token):
394
        """Get user's quotas
395

396
        Keyword arguments:
397
        token   -- user's token (string)
398

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

402
        """
403
        return self._call_astakos(token, copy(API_QUOTAS))
404

    
405
    # ----------------------------------
406
    # do a GET to ``API_SERVICE_QUOTAS``
407
    def service_get_quotas(self, token, user=None):
408
        """Get all quotas for resources associated with the service
409

410
        Keyword arguments:
411
        token   -- service's token (string)
412
        user    -- optionally, the uuid of a specific user
413

414
        In case of success return a dict of dicts of dicts with current quotas
415
        for all users, or of a specified user, if user argument is set.
416
        Otherwise raise an AstakosClientException
417

418
        """
419
        query = copy(API_SERVICE_QUOTAS)
420
        if user is not None:
421
            query += "?user=" + user
422
        return self._call_astakos(token, query)
423

    
424
    # ----------------------------------
425
    # do a POST to ``API_COMMISSIONS``
426
    def issue_commission(self, token, request):
427
        """Issue a commission
428

429
        Keyword arguments:
430
        token   -- service's token (string)
431
        request -- commision request (dict)
432

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

436
        """
437
        req_headers = {'content-type': 'application/json'}
438
        req_body = parse_request(request, self.logger)
439
        try:
440
            response = self._call_astakos(token, copy(API_COMMISSIONS),
441
                                          req_headers, req_body, "POST")
442
        except AstakosClientException as err:
443
            if err.status == 413:
444
                raise QuotaLimit(err.message, err.details)
445
            else:
446
                raise
447

    
448
        if "serial" in response:
449
            return response['serial']
450
        else:
451
            m = "issue_commission_core request returned %s. No serial found" \
452
                % response
453
            self.logger.error(m)
454
            raise AstakosClientException(m)
455

    
456
    def issue_one_commission(self, token, holder, source, provisions,
457
                             name="", force=False, auto_accept=False):
458
        """Issue one commission (with specific holder and source)
459

460
        keyword arguments:
461
        token       -- service's token (string)
462
        holder      -- user's id (string)
463
        source      -- commission's source (ex system) (string)
464
        provisions  -- resources with their quantity (dict from string to int)
465
        name        -- description of the commission (string)
466
        force       -- force this commission (boolean)
467
        auto_accept -- auto accept this commission (boolean)
468

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

473
        """
474
        check_input("issue_one_commission", self.logger,
475
                    holder=holder, source=source,
476
                    provisions=provisions)
477

    
478
        request = {}
479
        request["force"] = force
480
        request["auto_accept"] = auto_accept
481
        request["name"] = name
482
        try:
483
            request["provisions"] = []
484
            for resource, quantity in provisions.iteritems():
485
                t = {"holder": holder, "source": source,
486
                     "resource": resource, "quantity": quantity}
487
                request["provisions"].append(t)
488
        except Exception as err:
489
            self.logger.error(str(err))
490
            raise BadValue(str(err))
491

    
492
        return self.issue_commission(token, request)
493

    
494
    # ----------------------------------
495
    # do a GET to ``API_COMMISSIONS``
496
    def get_pending_commissions(self, token):
497
        """Get Pending Commissions
498

499
        Keyword arguments:
500
        token   -- service's token (string)
501

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

505
        """
506
        return self._call_astakos(token, copy(API_COMMISSIONS))
507

    
508
    # ----------------------------------
509
    # do a GET to ``API_COMMISSIONS``/<serial>
510
    def get_commission_info(self, token, serial):
511
        """Get Description of a Commission
512

513
        Keyword arguments:
514
        token   -- service's token (string)
515
        serial  -- commission's id (int)
516

517
        In case of success return a dict of dicts containing
518
        informations (details) about the requested commission
519

520
        """
521
        check_input("get_commission_info", self.logger, serial=serial)
522

    
523
        path = API_COMMISSIONS + "/" + str(serial)
524
        return self._call_astakos(token, path)
525

    
526
    # ----------------------------------
527
    # do a POST to ``API_COMMISSIONS``/<serial>/action"
528
    def commission_action(self, token, serial, action):
529
        """Perform a commission action
530

531
        Keyword arguments:
532
        token   -- service's token (string)
533
        serial  -- commission's id (int)
534
        action  -- action to perform, currently accept/reject (string)
535

536
        In case of success return nothing.
537

538
        """
539
        check_input("commission_action", self.logger,
540
                    serial=serial, action=action)
541

    
542
        path = API_COMMISSIONS + "/" + str(serial) + "/action"
543
        req_headers = {'content-type': 'application/json'}
544
        req_body = parse_request({str(action): ""}, self.logger)
545
        self._call_astakos(token, path, req_headers, req_body, "POST")
546

    
547
    def accept_commission(self, token, serial):
548
        """Accept a commission (see commission_action)"""
549
        self.commission_action(token, serial, "accept")
550

    
551
    def reject_commission(self, token, serial):
552
        """Reject a commission (see commission_action)"""
553
        self.commission_action(token, serial, "reject")
554

    
555
    # ----------------------------------
556
    # do a POST to ``API_COMMISSIONS_ACTION``
557
    def resolve_commissions(self, token, accept_serials, reject_serials):
558
        """Resolve multiple commissions at once
559

560
        Keyword arguments:
561
        token           -- service's token (string)
562
        accept_serials  -- commissions to accept (list of ints)
563
        reject_serials  -- commissions to reject (list of ints)
564

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

569
        """
570
        check_input("resolve_commissions", self.logger,
571
                    accept_serials=accept_serials,
572
                    reject_serials=reject_serials)
573

    
574
        path = copy(API_COMMISSIONS_ACTION)
575
        req_headers = {'content-type': 'application/json'}
576
        req_body = parse_request({"accept": accept_serials,
577
                                  "reject": reject_serials},
578
                                 self.logger)
579
        return self._call_astakos(token, path, req_headers, req_body, "POST")
580

    
581
    # ----------------------------
582
    # do a GET to ``API_PROJECTS``
583
    def get_projects(self, token, name=None, state=None, owner=None):
584
        """Retrieve all accessible projects
585

586
        Arguments:
587
        token -- user's token (string)
588
        name  -- filter by name (optional)
589
        state -- filter by state (optional)
590
        owner -- filter by owner (optional)
591

592
        In case of success, return a list of project descriptions.
593
        """
594
        path = API_PROJECTS
595
        filters = {}
596
        if name is not None:
597
            filters["name"] = name
598
        if state is not None:
599
            filters["state"] = state
600
        if owner is not None:
601
            filters["owner"] = owner
602
        req_headers = {'content-type': 'application/json'}
603
        req_body = (parse_request({"filter": filters}, self.logger)
604
                    if filters else None)
605
        return self._call_astakos(token, path, req_headers, req_body)
606

    
607
    # -----------------------------------------
608
    # do a GET to ``API_PROJECTS``/<project_id>
609
    def get_project(self, token, project_id):
610
        """Retrieve project description, if accessible
611

612
        Arguments:
613
        token      -- user's token (string)
614
        project_id -- project identifier
615

616
        In case of success, return project description.
617
        """
618
        path = join_urls(API_PROJECTS, str(project_id))
619
        return self._call_astakos(token, path)
620

    
621
    # -----------------------------
622
    # do a POST to ``API_PROJECTS``
623
    def create_project(self, token, specs):
624
        """Submit application to create a new project
625

626
        Arguments:
627
        token -- user's token (string)
628
        specs -- dict describing a project
629

630
        In case of success, return project and application identifiers.
631
        """
632
        path = API_PROJECTS
633
        req_headers = {'content-type': 'application/json'}
634
        req_body = parse_request(specs, self.logger)
635
        return self._call_astakos(token, path, req_headers, req_body, "POST")
636

    
637
    # ------------------------------------------
638
    # do a POST to ``API_PROJECTS``/<project_id>
639
    def modify_project(self, token, project_id, specs):
640
        """Submit application to modify an existing project
641

642
        Arguments:
643
        token      -- user's token (string)
644
        project_id -- project identifier
645
        specs      -- dict describing a project
646

647
        In case of success, return project and application identifiers.
648
        """
649
        path = join_urls(API_PROJECTS, str(project_id))
650
        req_headers = {'content-type': 'application/json'}
651
        req_body = parse_request(specs, self.logger)
652
        return self._call_astakos(token, path, req_headers, req_body, "POST")
653

    
654
    # -------------------------------------------------
655
    # do a POST to ``API_PROJECTS``/<project_id>/action
656
    def project_action(self, token, project_id, action, reason=""):
657
        """Perform action on a project
658

659
        Arguments:
660
        token      -- user's token (string)
661
        project_id -- project identifier
662
        action     -- action to perform, one of "suspend", "unsuspend",
663
                      "terminate", "reinstate"
664
        reason     -- reason of performing the action
665

666
        In case of success, return nothing.
667
        """
668
        path = join_urls(API_PROJECTS, str(project_id))
669
        path = join_urls(path, "action")
670
        req_headers = {'content-type': 'application/json'}
671
        req_body = parse_request({action: reason}, self.logger)
672
        return self._call_astakos(token, path, req_headers, req_body, "POST")
673

    
674
    # --------------------------------
675
    # do a GET to ``API_APPLICATIONS``
676
    def get_applications(self, token, project=None):
677
        """Retrieve all accessible applications
678

679
        Arguments:
680
        token   -- user's token (string)
681
        project -- filter by project (optional)
682

683
        In case of success, return a list of application descriptions.
684
        """
685
        path = API_APPLICATIONS
686
        req_headers = {'content-type': 'application/json'}
687
        body = {"project": project} if project is not None else None
688
        req_body = parse_request(body, self.logger) if body else None
689
        return self._call_astakos(token, path, req_headers, req_body)
690

    
691
    # -----------------------------------------
692
    # do a GET to ``API_APPLICATIONS``/<app_id>
693
    def get_application(self, token, app_id):
694
        """Retrieve application description, if accessible
695

696
        Arguments:
697
        token  -- user's token (string)
698
        app_id -- application identifier
699

700
        In case of success, return application description.
701
        """
702
        path = join_urls(API_APPLICATIONS, str(app_id))
703
        return self._call_astakos(token, path)
704

    
705
    # -------------------------------------------------
706
    # do a POST to ``API_APPLICATIONS``/<app_id>/action
707
    def application_action(self, token, app_id, action, reason=""):
708
        """Perform action on an application
709

710
        Arguments:
711
        token  -- user's token (string)
712
        app_id -- application identifier
713
        action -- action to perform, one of "approve", "deny",
714
                  "dismiss", "cancel"
715
        reason -- reason of performing the action
716

717
        In case of success, return nothing.
718
        """
719
        path = join_urls(API_APPLICATIONS, str(app_id))
720
        path = join_urls(path, "action")
721
        req_headers = {'content-type': 'application/json'}
722
        req_body = parse_request({action: reason}, self.logger)
723
        return self._call_astakos(token, path, req_headers, req_body, "POST")
724

    
725
    # -------------------------------
726
    # do a GET to ``API_MEMBERSHIPS``
727
    def get_memberships(self, token, project=None):
728
        """Retrieve all accessible memberships
729

730
        Arguments:
731
        token   -- user's token (string)
732
        project -- filter by project (optional)
733

734
        In case of success, return a list of membership descriptions.
735
        """
736
        path = API_MEMBERSHIPS
737
        req_headers = {'content-type': 'application/json'}
738
        body = {"project": project} if project is not None else None
739
        req_body = parse_request(body, self.logger) if body else None
740
        return self._call_astakos(token, path, req_headers, req_body)
741

    
742
    # -----------------------------------------
743
    # do a GET to ``API_MEMBERSHIPS``/<memb_id>
744
    def get_membership(self, token, memb_id):
745
        """Retrieve membership description, if accessible
746

747
        Arguments:
748
        token   -- user's token (string)
749
        memb_id -- membership identifier
750

751
        In case of success, return membership description.
752
        """
753
        path = join_urls(API_MEMBERSHIPS, str(memb_id))
754
        return self._call_astakos(token, path)
755

    
756
    # -------------------------------------------------
757
    # do a POST to ``API_MEMBERSHIPS``/<memb_id>/action
758
    def membership_action(self, token, memb_id, action, reason=""):
759
        """Perform action on a membership
760

761
        Arguments:
762
        token   -- user's token (string)
763
        memb_id -- membership identifier
764
        action  -- action to perform, one of "leave", "cancel", "accept",
765
                   "reject", "remove"
766
        reason  -- reason of performing the action
767

768
        In case of success, return nothing.
769
        """
770
        path = join_urls(API_MEMBERSHIPS, str(memb_id))
771
        path = join_urls(path, "action")
772
        req_headers = {'content-type': 'application/json'}
773
        req_body = parse_request({action: reason}, self.logger)
774
        return self._call_astakos(token, path, req_headers, req_body, "POST")
775

    
776
    # --------------------------------
777
    # do a POST to ``API_MEMBERSHIPS``
778
    def join_project(self, token, project_id):
779
        """Join a project
780

781
        Arguments:
782
        token      -- user's token (string)
783
        project_id -- project identifier
784

785
        In case of success, return membership identifier.
786
        """
787
        path = API_MEMBERSHIPS
788
        req_headers = {'content-type': 'application/json'}
789
        body = {"join": {"project": project_id}}
790
        req_body = parse_request(body, self.logger)
791
        return self._call_astakos(token, path, req_headers, req_body, "POST")
792

    
793
    # --------------------------------
794
    # do a POST to ``API_MEMBERSHIPS``
795
    def enroll_member(self, token, project_id, email):
796
        """Enroll a user in a project
797

798
        Arguments:
799
        token      -- user's token (string)
800
        project_id -- project identifier
801
        email      -- user identified by email
802

803
        In case of success, return membership identifier.
804
        """
805
        path = API_MEMBERSHIPS
806
        req_headers = {'content-type': 'application/json'}
807
        body = {"enroll": {"project": project_id, "user": email}}
808
        req_body = parse_request(body, self.logger)
809
        return self._call_astakos(token, path, req_headers, req_body, "POST")
810

    
811
# --------------------------------------------------------------------
812
# Private functions
813
# We want _doRequest to be a distinct function
814
# so that we can replace it during unit tests.
815
def _do_request(conn, method, url, **kwargs):
816
    """The actual request. This function can easily be mocked"""
817
    conn.request(method, url, **kwargs)
818
    response = conn.getresponse()
819
    length = response.getheader('content-length', None)
820
    data = response.read(length)
821
    status = int(response.status)
822
    message = response.reason
823
    return (message, data, status)