__init__.py 17.4 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

import logging
import urlparse
36
import urllib
37
import hashlib
38
from copy import copy
39 40

import simplejson
41
from astakosclient.utils import retry, scheme_to_class, parse_request
42
from astakosclient.errors import \
43
    AstakosClientException, Unauthorized, BadRequest, NotFound, Forbidden, \
44
    NoUserName, NoUUID, BadValue, QuotaLimit, InvalidResponse
45 46


47
# --------------------------------------------------------------------
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
48
# Astakos Client Class
49

50
def get_token_from_cookie(request, cookie_name):
51 52 53 54 55 56 57 58 59 60 61 62 63 64
    """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]
    except:
        return None


Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
65 66 67 68
class AstakosClient():
    """AstakosClient Class Implementation"""

    # ----------------------------------
69 70
    def __init__(self, astakos_url, retry=0,
                 use_pool=False, pool_size=8, logger=None):
71
        """Initialize AstakosClient Class
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
72 73 74 75

        Keyword arguments:
        astakos_url -- i.e https://accounts.example.com (string)
        use_pool    -- use objpool for http requests (boolean)
76
        retry       -- how many time to retry (integer)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
77 78 79 80
        logger      -- pass a different logger

        """
        if logger is None:
81 82 83 84
            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
85
            logger = logging.getLogger("astakosclient")
86
        logger.debug("Intialize AstakosClient: astakos_url = %s, "
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
87 88 89 90 91
                     "use_pool = %s" % (astakos_url, use_pool))

        if not astakos_url:
            m = "Astakos url not given"
            logger.error(m)
92
            raise BadValue(m)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
93 94 95

        # Check for supported scheme
        p = urlparse.urlparse(astakos_url)
96
        conn_class = scheme_to_class(p.scheme, use_pool, pool_size)
97
        if conn_class is None:
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
98 99
            m = "Unsupported scheme: %s" % p.scheme
            logger.error(m)
100
            raise BadValue(m)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
101

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
102
        # Save astakos_url etc. in our class
103
        self.retry = retry
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
104 105 106
        self.logger = logger
        self.netloc = p.netloc
        self.scheme = p.scheme
107
        self.conn_class = conn_class
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
108

109 110
    # ----------------------------------
    @retry
111 112
    def _call_astakos(self, token, request_path,
                      headers=None, body=None, method="GET"):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
113
        """Make the actual call to Astakos Service"""
114 115 116 117 118 119
        if token is not None:
            hashed_token = hashlib.sha1()
            hashed_token.update(token)
            using_token = "using token %s" % (hashed_token.hexdigest())
        else:
            using_token = "without using token"
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
120
        self.logger.debug(
121 122
            "Make a %s request to %s %s with headers %s and body %s"
            % (method, request_path, using_token, headers, body))
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
123

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
124
        # Check Input
125 126 127 128
        if headers is None:
            headers = {}
        if body is None:
            body = {}
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
129 130
        if request_path[0] != '/':
            request_path = "/" + request_path
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
131

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
132 133
        # Build request's header and body
        kwargs = {}
134
        kwargs['headers'] = copy(headers)
135 136
        if token is not None:
            kwargs['headers']['X-Auth-Token'] = token
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
137
        if body:
138
            kwargs['body'] = copy(body)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
139 140 141 142 143 144
            kwargs['headers'].setdefault(
                'content-type', 'application/octet-stream')
        kwargs['headers'].setdefault('content-length',
                                     len(body) if body else 0)

        try:
145 146 147
            # Get the connection object
            with self.conn_class(self.netloc) as conn:
                # Send request
148
                (message, data, status) = \
149
                    _do_request(conn, method, request_path, **kwargs)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
150
        except Exception as err:
151
            self.logger.error("Failed to send request: %s" % repr(err))
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
152 153 154 155
            raise AstakosClientException(str(err))

        # Return
        self.logger.debug("Request returned with status %s" % status)
156
        if status == 400:
157
            raise BadRequest(message, data)
158
        elif status == 401:
159
            raise Unauthorized(message, data)
160
        elif status == 403:
161
            raise Forbidden(message, data)
162
        elif status == 404:
163
            raise NotFound(message, data)
164
        elif status < 200 or status >= 300:
165
            raise AstakosClientException(message, data, status)
166 167 168 169 170 171 172

        try:
            if data:
                return simplejson.loads(unicode(data))
            else:
                return ""
        except Exception as err:
173 174
            self.logger.error("Cannot parse response \"%s\" with simplejson: %s"
                              % (data, str(err)))
175
            raise InvalidResponse(str(err), data)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
176 177

    # ------------------------
178
    # GET /im/authenticate
179
    def get_user_info(self, token, usage=False):
180
        """Authenticate user and get user's info as a dictionary
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
181 182

        Keyword arguments:
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
183
        token   -- user's token (string)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
184 185 186 187 188 189
        usage   -- return usage information for user (boolean)

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

        """
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
190
        # Send request
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
191 192 193
        auth_path = "/im/authenticate"
        if usage:
            auth_path += "?usage=1"
194
        return self._call_astakos(token, auth_path)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
195 196

    # ----------------------------------
197 198
    # POST /user_catalogs (or /service/api/user_catalogs)
    #   with {'uuids': uuids}
199
    def _uuid_catalog(self, token, uuids, req_path):
200
        req_headers = {'content-type': 'application/json'}
201
        req_body = parse_request({'uuids': uuids}, self.logger)
202
        data = self._call_astakos(
203
            token, req_path, req_headers, req_body, "POST")
204 205 206
        if "uuid_catalog" in data:
            return data.get("uuid_catalog")
        else:
207
            m = "_uuid_catalog request returned %s. No uuid_catalog found" \
208 209 210
                % data
            self.logger.error(m)
            raise AstakosClientException(m)
211

212
    def get_usernames(self, token, uuids):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
213 214 215
        """Return a uuid_catalog dictionary for the given uuids

        Keyword arguments:
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
216
        token   -- user's token (string)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
217 218 219 220 221 222 223
        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

        """
        req_path = "/user_catalogs"
224
        return self._uuid_catalog(token, uuids, req_path)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
225

226 227
    def get_username(self, token, uuid):
        """Return the user name of a uuid (see get_usernames)"""
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
228 229 230
        if not uuid:
            m = "No uuid was given"
            self.logger.error(m)
231
            raise BadValue(m)
232
        uuid_dict = self.get_usernames(token, [uuid])
233 234 235
        if uuid in uuid_dict:
            return uuid_dict.get(uuid)
        else:
236
            raise NoUserName(uuid)
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
237

238
    def service_get_usernames(self, token, uuids):
239 240
        """Return a uuid_catalog dict using a service's token"""
        req_path = "/service/api/user_catalogs"
241
        return self._uuid_catalog(token, uuids, req_path)
242

243
    def service_get_username(self, token, uuid):
244 245 246 247
        """Return the displayName of a uuid using a service's token"""
        if not uuid:
            m = "No uuid was given"
            self.logger.error(m)
248
            raise BadValue(m)
249
        uuid_dict = self.service_get_usernames(token, [uuid])
250 251 252
        if uuid in uuid_dict:
            return uuid_dict.get(uuid)
        else:
253
            raise NoUserName(uuid)
254

255
    # ----------------------------------
256 257
    # POST /user_catalogs (or /service/api/user_catalogs)
    #   with {'displaynames': display_names}
258
    def _displayname_catalog(self, token, display_names, req_path):
259
        req_headers = {'content-type': 'application/json'}
260
        req_body = parse_request({'displaynames': display_names}, self.logger)
261
        data = self._call_astakos(
262
            token, req_path, req_headers, req_body, "POST")
263 264 265
        if "displayname_catalog" in data:
            return data.get("displayname_catalog")
        else:
266
            m = "_displayname_catalog request returned %s. " \
267 268 269
                "No displayname_catalog found" % data
            self.logger.error(m)
            raise AstakosClientException(m)
270

271
    def get_uuids(self, token, display_names):
272 273 274 275 276 277 278 279 280 281 282
        """Return a displayname_catalog for the given names

        Keyword arguments:
        token           -- user's token (string)
        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

        """
        req_path = "/user_catalogs"
283
        return self._displayname_catalog(token, display_names, req_path)
284

285
    def get_uuid(self, token, display_name):
286 287 288 289
        """Return the uuid of a name (see getUUIDs)"""
        if not display_name:
            m = "No display_name was given"
            self.logger.error(m)
290
            raise BadValue(m)
291
        name_dict = self.get_uuids(token, [display_name])
292 293 294 295
        if display_name in name_dict:
            return name_dict.get(display_name)
        else:
            raise NoUUID(display_name)
296

297
    def service_get_uuids(self, token, display_names):
298 299
        """Return a display_name catalog using a service's token"""
        req_path = "/service/api/user_catalogs"
300
        return self._displayname_catalog(token, display_names, req_path)
301

302
    def service_get_uuid(self, token, display_name):
303 304 305 306
        """Return the uuid of a name using a service's token"""
        if not display_name:
            m = "No display_name was given"
            self.logger.error(m)
307
            raise BadValue(m)
308
        name_dict = self.service_get_uuids(token, [display_name])
309 310 311 312
        if display_name in name_dict:
            return name_dict.get(display_name)
        else:
            raise NoUUID(display_name)
313

Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
314
    # ----------------------------------
315
    # GET "/im/get_services"
316
    def get_services(self):
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
317
        """Return a list of dicts with the registered services"""
318 319 320 321 322 323 324
        return self._call_astakos(None, "/im/get_services")

    # ----------------------------------
    # GET "/astakos/api/resources"
    def get_resources(self):
        """Return a dict of dicts with the available resources"""
        return self._call_astakos(None, "/astakos/api/resources")
Ilias Tsitsimpis's avatar
Ilias Tsitsimpis committed
325

326 327 328
    # ----------------------------------
    # GET "/astakos/api/quotas"
    def get_quotas(self, token):
329 330 331 332 333 334 335 336 337
        """Get user's quotas

        Keyword arguments:
        token   -- user's token (string)

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

        """
338 339
        return self._call_astakos(token, "/astakos/api/quotas")

340 341 342 343 344 345
    # ----------------------------------
    # POST "/astakos/api/commisions"
    def issue_commission(self, token, request):
        """Issue a commission

        Keyword arguments:
346
        token   -- service's token (string)
347 348 349 350 351 352 353
        request -- commision request (dict)

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

        """
        req_headers = {'content-type': 'application/json'}
354
        req_body = parse_request(request, self.logger)
355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371
        try:
            response = self._call_astakos(token, "/astakos/api/commissions",
                                          req_headers, req_body, "POST")
        except AstakosClientException as err:
            if err.status == 413:
                raise QuotaLimit(err.message, err.details)
            else:
                raise

        if "serial" in response:
            return response['serial']
        else:
            m = "issue_commission_core request returned %s. No serial found" \
                % response
            self.logger.error(m)
            raise AstakosClientException(m)

372 373 374 375 376 377
    # ----------------------------------
    # GET "/astakos/api/commissions"
    def get_pending_commissions(self, token):
        """Get Pending Commissions

        Keyword arguments:
378
        token   -- service's token (string)
379 380 381 382 383 384 385

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

        """
        return self._call_astakos(token, "/astakos/api/commissions")

386 387 388 389 390 391
    # ----------------------------------
    # GET "/astakos/api/commissions/<serial>
    def get_commission_info(self, token, serial):
        """Get Description of a Commission

        Keyword arguments:
392
        token   -- service's token (string)
393 394 395 396 397 398 399 400 401 402 403 404 405 406
        serial  -- commission's id (int)

        In case of success return a dict of dicts containing
        informations (details) about the requests commission

        """
        if not serial:
            m = "Commissions serial not given"
            self.logger.error(m)
            raise BadValue(m)

        path = "/astakos/api/commissions/" + str(serial)
        return self._call_astakos(token, path)

407
    # ----------------------------------
408
    # POST "/astakos/api/commissions/<serial>/action"
409 410
    def commission_action(self, token, serial, action):
        """Perform a commission action
411 412

        Keyword arguments:
413
        token   -- service's token (string)
414 415 416 417 418 419 420
        serial  -- commission's id (int)
        action  -- action to perform, currently accept/reject (string)

        In case of success return nothing.

        """
        if not serial:
421
            m = "Commission's serial not given"
422 423 424 425 426 427 428 429 430
            self.logger.error(m)
            raise BadValue(m)
        if not action:
            m = "Action not given"
            self.logger.error(m)
            raise BadValue(m)

        path = "/astakos/api/commissions/" + str(serial) + "/action"
        req_headers = {'content-type': 'application/json'}
431
        req_body = parse_request({str(action): ""}, self.logger)
432 433
        self._call_astakos(token, path, req_headers, req_body, "POST")

434 435 436
    def accept_commission(self, token, serial):
        """Accept a commission (see commission_action)"""
        self.commission_action(token, serial, "accept")
437

438 439 440
    def reject_commission(self, token, serial):
        """Reject a commission (see commission_action)"""
        self.commission_action(token, serial, "reject")
441

442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472
    # ----------------------------------
    # POST "/astakos/api/commissions/action"
    def resolve_commissions(self, token, accept_serials, reject_serials):
        """Resolve multiple commissions at once

        Keyword arguments:
        token           -- service's token (string)
        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.

        """
        if not accept_serials:
            m = "accept_serials parameter not given"
            self.logger.error(m)
            raise BadValue(m)
        if not reject_serials:
            m = "reject_serials parameter not given"
            self.logger.error(m)
            raise BadValue(m)

        path = "/astakos/api/commissions/action"
        req_headers = {'content-type': 'application/json'}
        req_body = parse_request({"accept": accept_serials,
                                  "reject": reject_serials},
                                 self.logger)
        return self._call_astakos(token, path, req_headers, req_body, "POST")

473

474 475
# --------------------------------------------------------------------
# Private functions
476 477
# We want _doRequest to be a distinct function
# so that we can replace it during unit tests.
478
def _do_request(conn, method, url, **kwargs):
479 480 481 482 483 484
    """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)
485 486
    message = response.reason
    return (message, data, status)