Statistics
| Branch: | Tag: | Revision:

root / snf-django-lib / snf_django / lib / api / __init__.py @ f9d665e4

History | View | Annotate | Download (13.1 kB)

1
# Copyright 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
import sys
34
from functools import wraps
35
from traceback import format_exc
36
from time import time
37
from logging import getLogger
38
from wsgiref.handlers import format_date_time
39

    
40
from django.http import HttpResponse
41
from django.utils import cache
42
from django.utils import simplejson as json
43
from django.template.loader import render_to_string
44
from django.views.decorators import csrf
45

    
46
from astakosclient import AstakosClient
47
from astakosclient.errors import AstakosClientException
48
from django.conf import settings
49
from snf_django.lib.api import faults
50

    
51
import itertools
52

    
53
log = getLogger(__name__)
54
django_logger = getLogger("django.request")
55

    
56

    
57
def get_token(request):
58
    """Get the Authentication Token of a request."""
59
    token = request.GET.get("X-Auth-Token", None)
60
    if not token:
61
        token = request.META.get("HTTP_X_AUTH_TOKEN", None)
62
    return token
63

    
64

    
65
def api_method(http_method=None, token_required=True, user_required=True,
66
               logger=None, format_allowed=True, astakos_auth_url=None,
67
               serializations=None, strict_serlization=False):
68
    """Decorator function for views that implement an API method."""
69
    if not logger:
70
        logger = log
71

    
72
    serializations = serializations or ['json', 'xml']
73

    
74
    def decorator(func):
75
        @wraps(func)
76
        def wrapper(request, *args, **kwargs):
77
            try:
78
                # Get the requested serialization format
79
                serialization = get_serialization(
80
                    request, format_allowed, serializations[0])
81

    
82
                # If guessed serialization is not supported, fallback to
83
                # the default serialization or return an API error in case
84
                # strict serialization flag is set.
85
                if not serialization in serializations:
86
                    if strict_serlization:
87
                        raise faults.BadRequest(("%s serialization not "
88
                                                "supported") % serialization)
89
                    serialization = serializations[0]
90
                request.serialization = serialization
91

    
92
                # Check HTTP method
93
                if http_method and request.method != http_method:
94
                    raise faults.NotAllowed("Method not allowed",
95
                                            allowed_methods=[http_method])
96

    
97
                # Get authentication token
98
                request.x_auth_token = None
99
                if token_required or user_required:
100
                    token = get_token(request)
101
                    if not token:
102
                        msg = "Access denied. No authentication token"
103
                        raise faults.Unauthorized(msg)
104
                    request.x_auth_token = token
105

    
106
                # Authenticate
107
                if user_required:
108
                    assert(token_required), "Can not get user without token"
109
                    astakos_url = astakos_auth_url
110
                    if astakos_url is None:
111
                        try:
112
                            astakos_url = settings.ASTAKOS_AUTH_URL
113
                        except AttributeError:
114
                            logger.error("Cannot authenticate without having"
115
                                         " an Astakos Authentication URL")
116
                            raise
117
                    astakos = AstakosClient(token, astakos_url,
118
                                            use_pool=True,
119
                                            retry=2,
120
                                            logger=logger)
121
                    user_info = astakos.authenticate()
122
                    request.user_uniq = user_info["access"]["user"]["id"]
123
                    request.user = user_info
124

    
125
                # Get the response object
126
                response = func(request, *args, **kwargs)
127

    
128
                # Fill in response variables
129
                update_response_headers(request, response)
130
                return response
131
            except faults.Fault as fault:
132
                if fault.code >= 500:
133
                    django_logger.error("Unexpected API Error: %s",
134
                                        request.path,
135
                                        exc_info=sys.exc_info(),
136
                                        extra={
137
                                            "status_code": fault.code,
138
                                            "request": request})
139
                return render_fault(request, fault)
140
            except AstakosClientException as err:
141
                fault = faults.Fault(message=err.message,
142
                                     details=err.details,
143
                                     code=err.status)
144
                if fault.code >= 500:
145
                    django_logger.error("Unexpected AstakosClient Error: %s",
146
                                        request.path,
147
                                        exc_info=sys.exc_info(),
148
                                        extra={
149
                                            "status_code": fault.code,
150
                                            "request": request})
151
                return render_fault(request, fault)
152
            except:
153
                django_logger.error("Internal Server Error: %s", request.path,
154
                                    exc_info=sys.exc_info(),
155
                                    extra={
156
                                        "status_code": '500',
157
                                        "request": request})
158
                fault = faults.InternalServerError("Unexpected error")
159
                return render_fault(request, fault)
160
        return csrf.csrf_exempt(wrapper)
161
    return decorator
162

    
163

    
164
def get_serialization(request, format_allowed=True,
165
                      default_serialization="json"):
166
    """Return the serialization format requested.
167

168
    Valid formats are 'json' and 'xml' and 'text'
169
    """
170

    
171
    if not format_allowed:
172
        return "text"
173

    
174
    # Try to get serialization from 'format' parameter
175
    _format = request.GET.get("format")
176
    if _format:
177
        if _format == "json":
178
            return "json"
179
        elif _format == "xml":
180
            return "xml"
181

    
182
    # Try to get serialization from path
183
    path = request.path
184
    if path.endswith(".json"):
185
        return "json"
186
    elif path.endswith(".xml"):
187
        return "xml"
188

    
189
    for item in request.META.get("HTTP_ACCEPT", "").split(","):
190
        accept, sep, rest = item.strip().partition(";")
191
        if accept == "application/json":
192
            return "json"
193
        elif accept == "application/xml":
194
            return "xml"
195

    
196
    return default_serialization
197

    
198

    
199
def update_response_headers(request, response):
200
    if not getattr(response, "override_serialization", False):
201
        serialization = request.serialization
202
        if serialization == "xml":
203
            response["Content-Type"] = "application/xml; charset=UTF-8"
204
        elif serialization == "json":
205
            response["Content-Type"] = "application/json; charset=UTF-8"
206
        elif serialization == "text":
207
            response["Content-Type"] = "text/plain; charset=UTF-8"
208
        else:
209
            raise ValueError("Unknown serialization format '%s'" %
210
                             serialization)
211

    
212
    if settings.DEBUG or getattr(settings, "TEST", False):
213
        response["Date"] = format_date_time(time())
214

    
215
    if not response.has_header("Content-Length"):
216
        _base_content_is_iter = getattr(response, '_base_content_is_iter',
217
                                        None)
218
        if (_base_content_is_iter is not None and not _base_content_is_iter):
219
            response["Content-Length"] = len(response.content)
220
        else:
221
            if not (response.has_header('Content-Type') and
222
                    response['Content-Type'].startswith(
223
                        'multipart/byteranges')):
224
                # save response content from been consumed if it is an iterator
225
                response._container, data = itertools.tee(response._container)
226
                response["Content-Length"] = len(str(data))
227

    
228
    cache.add_never_cache_headers(response)
229
    # Fix Vary and Cache-Control Headers. Issue: #3448
230
    cache.patch_vary_headers(response, ('X-Auth-Token',))
231
    cache.patch_cache_control(response, no_cache=True, no_store=True,
232
                              must_revalidate=True)
233

    
234

    
235
def render_fault(request, fault):
236
    """Render an API fault to an HTTP response."""
237
    # If running in debug mode add exception information to fault details
238
    if settings.DEBUG or getattr(settings, "TEST", False):
239
        fault.details = format_exc()
240

    
241
    try:
242
        serialization = request.serialization
243
    except AttributeError:
244
        request.serialization = "json"
245
        serialization = "json"
246

    
247
    # Serialize the fault data to xml or json
248
    if serialization == "xml":
249
        data = render_to_string("fault.xml", {"fault": fault})
250
    else:
251
        d = {fault.name: {"code": fault.code,
252
                          "message": fault.message,
253
                          "details": fault.details}}
254
        data = json.dumps(d)
255

    
256
    response = HttpResponse(data, status=fault.code)
257
    if response.status_code == 405 and hasattr(fault, 'allowed_methods'):
258
        response['Allow'] = ','.join(fault.allowed_methods)
259
    update_response_headers(request, response)
260
    return response
261

    
262

    
263
@api_method(token_required=False, user_required=False)
264
def api_endpoint_not_found(request):
265
    raise faults.BadRequest("API endpoint not found")
266

    
267

    
268
@api_method(token_required=False, user_required=False)
269
def api_method_not_allowed(request, allowed_methods):
270
    raise faults.NotAllowed("Method not allowed",
271
                            allowed_methods=allowed_methods)
272

    
273

    
274
def allow_jsonp(key='callback'):
275
    """
276
    Wrapper to enable jsonp responses.
277
    """
278
    def wrapper(func):
279
        @wraps(func)
280
        def view_wrapper(request, *args, **kwargs):
281
            response = func(request, *args, **kwargs)
282
            if 'content-type' in response._headers and \
283
               response._headers['content-type'][1] == 'application/json':
284
                callback_name = request.GET.get(key, None)
285
                if callback_name:
286
                    response.content = "%s(%s)" % (callback_name,
287
                                                   response.content)
288
                    response._headers['content-type'] = ('Content-Type',
289
                                                         'text/javascript')
290
            return response
291
        return view_wrapper
292
    return wrapper
293

    
294

    
295
def user_in_groups(permitted_groups, logger=None):
296
    """Check that the request user belongs to one of permitted groups.
297

298
    Django view wrapper to check that the already identified request user
299
    belongs to one of the allowed groups.
300

301
    """
302
    if not logger:
303
        logger = log
304

    
305
    def decorator(func):
306
        @wraps(func)
307
        def wrapper(request, *args, **kwargs):
308
            if hasattr(request, "user") and request.user is not None:
309
                groups = request.user["access"]["user"]["roles"]
310
                groups = [g["name"] for g in groups]
311
            else:
312
                raise faults.Forbidden
313

    
314
            common_groups = set(groups) & set(permitted_groups)
315

    
316
            if not common_groups:
317
                msg = ("Not allowing access to '%s' by user '%s'. User does"
318
                       " not belong to a valid group. User groups: %s,"
319
                       " Required groups %s"
320
                       % (request.path, request.user, groups,
321
                          permitted_groups))
322
                logger.error(msg)
323
                raise faults.Forbidden
324

    
325
            logger.info("User '%s' in groups '%s' accessed view '%s'",
326
                        request.user_uniq, groups, request.path)
327

    
328
            return func(request, *args, **kwargs)
329
        return wrapper
330
    return decorator