| 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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
 | from django.contrib.sites.models import Site
from django.core.urlresolvers import reverse
from django.http import HttpResponse
from djangorestframework import emitters, parsers, authenticators
from djangorestframework.response import status, Response, ResponseException
from decimal import Decimal
import re
# TODO: Figure how out references and named urls need to work nicely
# TODO: POST on existing 404 URL, PUT on existing 404 URL
#
# NEXT: Exceptions on func() -> 500, tracebacks emitted if settings.DEBUG
#
__all__ = ['Resource']
class Resource(object):
    """Handles incoming requests and maps them to REST operations,
    performing authentication, input deserialization, input validation, output serialization."""
    # List of RESTful operations which may be performed on this resource.
    allowed_methods = ('GET',)
    anon_allowed_methods = ()
    # List of emitters the resource can serialize the response with, ordered by preference
    emitters = ( emitters.JSONEmitter,
                 emitters.DocumentingHTMLEmitter,
                 emitters.DocumentingXHTMLEmitter,
                 emitters.DocumentingPlainTextEmitter,
                 emitters.XMLEmitter )
    # List of content-types the resource can read from
    parsers = ( parsers.JSONParser,
                parsers.XMLParser,
                parsers.FormParser )
    
    # List of all authenticating methods to attempt
    authenticators = ( authenticators.UserLoggedInAuthenticator,
                       authenticators.BasicAuthenticator )
    # Optional form for input validation and presentation of HTML formatted responses.
    form = None
    # Map standard HTTP methods to function calls
    callmap = { 'GET': 'get', 'POST': 'post', 
                'PUT': 'put', 'DELETE': 'delete' }
    # Some reserved parameters to allow us to use standard HTML forms with our resource
    # Override any/all of these with None to disable them, or override them with another value to rename them.
    ACCEPT_QUERY_PARAM = '_accept'        # Allow override of Accept header in URL query params
    METHOD_PARAM = '_method'              # Allow POST overloading in form params
    CONTENTTYPE_PARAM = '_contenttype'    # Allow override of Content-Type header in form params (allows sending arbitrary content with standard forms)
    CONTENT_PARAM = '_content'            # Allow override of body content in form params (allows sending arbitrary content with standard forms) 
    CSRF_PARAM = 'csrfmiddlewaretoken'    # Django's CSRF token used in form params
    def __new__(cls, *args, **kwargs):
        """Make the class callable so it can be used as a Django view."""
        self = object.__new__(cls)
        if args:
            request = args[0]
            self.__init__(request)
            return self._handle_request(request, *args[1:], **kwargs)
        else:
            self.__init__()
            return self
    def __init__(self, request=None):
        """"""
        # Setup the resource context
        self.request = request
        self.response = None
        self.form_instance = None
        # These sets are determined now so that overridding classes can modify the various parameter names,
        # or set them to None to disable them. 
        self.RESERVED_FORM_PARAMS = set((self.METHOD_PARAM, self.CONTENTTYPE_PARAM, self.CONTENT_PARAM, self.CSRF_PARAM))
        self.RESERVED_QUERY_PARAMS = set((self.ACCEPT_QUERY_PARAM))
        self.RESERVED_FORM_PARAMS.discard(None)
        self.RESERVED_QUERY_PARAMS.discard(None)
    @property
    def name(self):
        """Provide a name for the resource.
        By default this is the class name, with 'CamelCaseNames' converted to 'Camel Case Names'."""
        class_name = self.__class__.__name__
        return re.sub('(((?<=[a-z])[A-Z])|([A-Z](?![A-Z]|$)))', ' \\1', class_name).strip()
    @property
    def description(self):
        """Provide a description for the resource.
        By default this is the class's docstring with leading line spaces stripped."""
        return re.sub(re.compile('^ +', re.MULTILINE), '', self.__doc__)
    @property
    def emitted_media_types(self):
        """Return an list of all the media types that this resource can emit."""
        return [emitter.media_type for emitter in self.emitters]
    @property
    def default_emitter(self):
        """Return the resource's most prefered emitter.
        (This emitter is used if the client does not send and Accept: header, or sends Accept: */*)"""
        return self.emitters[0]
    @property
    def parsed_media_types(self):
        """Return an list of all the media types that this resource can emit."""
        return [parser.media_type for parser in self.parsers]
    
    @property
    def default_parser(self):
        """Return the resource's most prefered emitter.
        (This has no behavioural effect, but is may be used by documenting emitters)"""        
        return self.parsers[0]
    def get(self, request, auth, *args, **kwargs):
        """Must be subclassed to be implemented."""
        self.not_implemented('GET')
    def post(self, request, auth, content, *args, **kwargs):
        """Must be subclassed to be implemented."""
        self.not_implemented('POST')
    def put(self, request, auth, content, *args, **kwargs):
        """Must be subclassed to be implemented."""
        self.not_implemented('PUT')
    def delete(self, request, auth, *args, **kwargs):
        """Must be subclassed to be implemented."""
        self.not_implemented('DELETE')
    def reverse(self, view, *args, **kwargs):
        """Return a fully qualified URI for a given view or resource.
        Add the domain using the Sites framework if possible, otherwise fallback to using the current request."""
        return self.add_domain(reverse(view, args=args, kwargs=kwargs))
    def not_implemented(self, operation):
        """Return an HTTP 500 server error if an operation is called which has been allowed by
        allowed_methods, but which has not been implemented."""
        raise ResponseException(status.HTTP_500_INTERNAL_SERVER_ERROR,
                                {'detail': '%s operation on this resource has not been implemented' % (operation, )})
    def add_domain(self, path):
        """Given a path, return an fully qualified URI.
        Use the Sites framework if possible, otherwise fallback to using the domain from the current request."""
        # Note that out-of-the-box the Sites framework uses the reserved domain 'example.com'
        # See RFC 2606 - http://www.faqs.org/rfcs/rfc2606.html
        try:
            site = Site.objects.get_current()
            if site.domain and site.domain != 'example.com':
                return 'http://%s%s' % (site.domain, path)
        except:
            pass
        return self.request.build_absolute_uri(path)
    
    
    def determine_method(self, request):
        """Determine the HTTP method that this request should be treated as.
        Allows PUT and DELETE tunneling via the _method parameter if METHOD_PARAM is set."""
        method = request.method.upper()
        if method == 'POST' and self.METHOD_PARAM and request.POST.has_key(self.METHOD_PARAM):
            method = request.POST[self.METHOD_PARAM].upper()
        
        return method
    def authenticate(self, request):
        """Attempt to authenticate the request, returning an authentication context or None.
        An authentication context may be any object, although in many cases it will be a User instance."""
        
        # Attempt authentication against each authenticator in turn,
        # and return None if no authenticators succeed in authenticating the request.
        for authenticator in self.authenticators:
            auth_context = authenticator(self).authenticate(request)
            if auth_context:
                return auth_context
        return None
    def check_method_allowed(self, method, auth):
        """Ensure the request method is permitted for this resource, raising a ResourceException if it is not."""
        if not method in self.callmap.keys():
            raise ResponseException(status.HTTP_501_NOT_IMPLEMENTED,
                                    {'detail': 'Unknown or unsupported method \'%s\'' % method})
        if not method in self.allowed_methods:
            raise ResponseException(status.HTTP_405_METHOD_NOT_ALLOWED,
                                    {'detail': 'Method \'%s\' not allowed on this resource.' % method})
        if auth is None and not method in self.anon_allowed_methods:
            raise ResponseException(status.HTTP_403_FORBIDDEN,
                                    {'detail': 'You do not have permission to access this resource. ' +
                                     'You may need to login or otherwise authenticate the request.'})
    def get_form(self, data=None):
        """Optionally return a Django Form instance, which may be used for validation
        and/or rendered by an HTML/XHTML emitter.
        
        If data is not None the form will be bound to data."""
        if self.form:
            if data:
                return self.form(data)
            else:
                return self.form()
        return None
  
  
    def cleanup_request(self, data, form_instance):
        """Perform any resource-specific data deserialization and/or validation
        after the initial HTTP content-type deserialization has taken place.
        
        Returns a tuple containing the cleaned up data, and optionally a form bound to that data.
        
        By default this uses form validation to filter the basic input into the required types."""
        if form_instance is None:
            return data
        
        # Default form validation does not check for additional invalid fields
        non_existent_fields = []
        for key in set(data.keys()) - set(form_instance.fields.keys()):
            non_existent_fields.append(key)
        if not form_instance.is_valid() or non_existent_fields:
            if not form_instance.errors and not non_existent_fields:
                # If no data was supplied the errors property will be None
                details = 'No content was supplied'
                
            else:
                # Add standard field errors
                details = dict((key, map(unicode, val)) for (key, val) in form_instance.errors.iteritems() if key != '__all__')
                # Add any non-field errors
                if form_instance.non_field_errors():
                    details['errors'] = form_instance.non_field_errors()
                # Add any non-existent field errors
                for key in non_existent_fields:
                    details[key] = ['This field does not exist']
            # Bail.  Note that we will still serialize this response with the appropriate content type 
            raise ResponseException(status.HTTP_400_BAD_REQUEST, {'detail': details})
        return form_instance.cleaned_data
    def cleanup_response(self, data):
        """Perform any resource-specific data filtering prior to the standard HTTP
        content-type serialization.
        Eg filter complex objects that cannot be serialized by json/xml/etc into basic objects that can."""
        return data
    def determine_parser(self, request):
        """Return the appropriate parser for the input, given the client's 'Content-Type' header,
        and the content types that this Resource knows how to parse."""
        content_type = request.META.get('CONTENT_TYPE', 'application/x-www-form-urlencoded')
        raw_content = request.raw_post_data
    
        split = content_type.split(';', 1)
        if len(split) > 1:
            content_type = split[0]
        content_type = content_type.strip()
        
        # If CONTENTTYPE_PARAM is turned on, and this is a standard POST form then allow the content type to be overridden
        if (content_type == 'application/x-www-form-urlencoded' and
            request.method == 'POST' and
            self.CONTENTTYPE_PARAM and
            self.CONTENT_PARAM and
            request.POST.get(self.CONTENTTYPE_PARAM, None) and
            request.POST.get(self.CONTENT_PARAM, None)):
            raw_content = request.POST[self.CONTENT_PARAM]
            content_type = request.POST[self.CONTENTTYPE_PARAM]
        # Create a list of list of (media_type, Parser) tuples
        media_type_to_parser = dict([(parser.media_type, parser) for parser in self.parsers])
        try:
            return (media_type_to_parser[content_type], raw_content)
        except KeyError:
            raise ResponseException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
                                    {'detail': 'Unsupported media type \'%s\'' % content_type})
    def determine_emitter(self, request):
        """Return the appropriate emitter for the output, given the client's 'Accept' header,
        and the content types that this Resource knows how to serve.
        
        See: RFC 2616, Section 14 - http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html"""
        if self.ACCEPT_QUERY_PARAM and request.GET.get(self.ACCEPT_QUERY_PARAM, None):
            # Use _accept parameter override
            accept_list = [request.GET.get(self.ACCEPT_QUERY_PARAM)]
        elif request.META.has_key('HTTP_ACCEPT'):
            # Use standard HTTP Accept negotiation
            accept_list = request.META["HTTP_ACCEPT"].split(',')
        else:
            # No accept header specified
            return self.default_emitter
        
        # Parse the accept header into a dict of {qvalue: set of media types}
        # We ignore mietype parameters
        accept_dict = {}    
        for token in accept_list:
            components = token.split(';')
            mimetype = components[0].strip()
            qvalue = Decimal('1.0')
            
            if len(components) > 1:
                # Parse items that have a qvalue eg text/html;q=0.9
                try:
                    (q, num) = components[-1].split('=')
                    if q == 'q':
                        qvalue = Decimal(num)
                except:
                    # Skip malformed entries
                    continue
            if accept_dict.has_key(qvalue):
                accept_dict[qvalue].add(mimetype)
            else:
                accept_dict[qvalue] = set((mimetype,))
        
        # Convert to a list of sets ordered by qvalue (highest first)
        accept_sets = [accept_dict[qvalue] for qvalue in sorted(accept_dict.keys(), reverse=True)]
       
        for accept_set in accept_sets:
            # Return any exact match
            for emitter in self.emitters:
                if emitter.media_type in accept_set:
                    return emitter
            # Return any subtype match
            for emitter in self.emitters:
                if emitter.media_type.split('/')[0] + '/*' in accept_set:
                    return emitter
            # Return default
            if '*/*' in accept_set:
                return self.default_emitter
      
        raise ResponseException(status.HTTP_406_NOT_ACCEPTABLE,
                                {'detail': 'Could not statisfy the client\'s Accept header',
                                 'available_types': self.emitted_media_types})
    def _handle_request(self, request, *args, **kwargs):
        """This method is the core of Resource, through which all requests are passed.
        Broadly this consists of the following procedure:
        0. ensure the operation is permitted
        1. deserialize request content into request data, using standard HTTP content types (PUT/POST only)
        2. cleanup and validate request data (PUT/POST only)
        3. call the core method to get the response data
        4. cleanup the response data
        5. serialize response data into response content, using standard HTTP content negotiation
        """
        emitter = None
        method = self.determine_method(request)
        try:
            # Before we attempt anything else determine what format to emit our response data with.
            emitter = self.determine_emitter(request)
            # Authenticate the request, and store any context so that the resource operations can
            # do more fine grained authentication if required.
            #
            # Typically the context will be a user, or None if this is an anonymous request,
            # but it could potentially be more complex (eg the context of a request key which
            # has been signed against a particular set of permissions)
            auth_context = self.authenticate(request)
            # Ensure the requested operation is permitted on this resource
            self.check_method_allowed(method, auth_context)
            # Get the appropriate create/read/update/delete function
            func = getattr(self, self.callmap.get(method, None))
    
            # Either generate the response data, deserializing and validating any request data
            # TODO: Add support for message bodys on other HTTP methods, as it is valid.
            if method in ('PUT', 'POST'):
                (parser, raw_content) = self.determine_parser(request)
                data = parser(self).parse(raw_content)
                self.form_instance = self.get_form(data)
                data = self.cleanup_request(data, self.form_instance)
                response = func(request, auth_context, data, *args, **kwargs)
            else:
                response = func(request, auth_context, *args, **kwargs)
            # Allow return value to be either Response, or an object, or None
            if isinstance(response, Response):
                self.response = response
            elif response is not None:
                self.response = Response(status.HTTP_200_OK, response)
            else:
                self.response = Response(status.HTTP_204_NO_CONTENT)
            # Pre-serialize filtering (eg filter complex objects into natively serializable types)
            self.response.cleaned_content = self.cleanup_response(self.response.raw_content)
        except ResponseException, exc:
            self.response = exc.response
            # Fall back to the default emitter if we failed to perform content negotiation
            if emitter is None:
                emitter = self.default_emitter
        # Always add these headers
        self.response.headers['Allow'] = ', '.join(self.allowed_methods)
        self.response.headers['Vary'] = 'Authenticate, Allow'
        # Serialize the response content
        if self.response.has_content_body:
            content = emitter(self).emit(output=self.response.cleaned_content)
        else:
            content = emitter(self).emit()
        # Build the HTTP Response
        # TODO: Check if emitter.mimetype is underspecified, or if a content-type header has been set
        resp = HttpResponse(content, mimetype=emitter.media_type, status=self.response.status)
        for (key, val) in self.response.headers.items():
            resp[key] = val
        return resp
 |