aboutsummaryrefslogtreecommitdiffstats
path: root/src/rest/resource.py
blob: b94854f584f1a4ae88cd69b8d499677bfa2d0274 (plain)
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
from django.contrib.sites.models import Site
from django.core.urlresolvers import reverse
from django.core.handlers.wsgi import STATUS_CODE_TEXT
from django.http import HttpResponse
from rest import emitters, parsers
from rest.status import Status, ResourceException
from decimal import Decimal
import re

# TODO: Authentication
# TODO: Display user login in top panel: http://stackoverflow.com/questions/806835/django-redirect-to-previous-page-after-login
# TODO: Return basic object, not tuple of status code, content, headers
# TODO: Take request, not headers
# TODO: Standard exception classes
# TODO: Figure how out references and named urls need to work nicely
# TODO: POST on existing 404 URL, PUT on existing 404 URL
#
# NEXT: Generic content form
# NEXT: Remove self.blah munging  (Add a ResponseContext object?)
# NEXT: Caching cleverness
# NEXT: Test non-existent fields on ModelResources
#
# FUTURE: Erroring on read-only fields

# Documentation, Release



class Resource(object):
    # List of RESTful operations which may be performed on this resource.
    allowed_operations = ('read',)
    anon_allowed_operations = ()

    # Optional form for input validation and presentation of HTML formatted responses. 
    form = None

    # List of content-types the resource can respond with, ordered by preference
    emitters = ( ('application/json', emitters.JSONEmitter),
                 ('text/html', emitters.HTMLEmitter),
                 ('application/xhtml+xml', emitters.HTMLEmitter),
                 ('text/plain', emitters.TextEmitter),
                 ('application/xml', emitters.XMLEmitter), )

    # List of content-types the resource can read from
    parsers = { 'application/json': parsers.JSONParser,
                'application/xml': parsers.XMLParser,
                'application/x-www-form-urlencoded': parsers.FormParser,
                'multipart/form-data': parsers.FormParser }

    # Map standard HTTP methods to RESTful operations
    CALLMAP = { 'GET': 'read', 'POST': 'create', 
                'PUT': 'update', 'DELETE': 'delete' }

    REVERSE_CALLMAP = dict([(val, key) for (key, val) in CALLMAP.items()])

    # Some reserved parameters to allow us to use standard HTML forms with our resource
    METHOD_PARAM = '_method'              # Allow POST overloading
    ACCEPT_PARAM = '_accept'              # Allow override of Accept header in GET requests
    CONTENTTYPE_PARAM = '_contenttype'    # Allow override of Content-Type header (allows sending arbitrary content with standard forms)
    CONTENT_PARAM = '_content'            # Allow override of body content (allows sending arbitrary content with standard forms) 
    CSRF_PARAM = 'csrfmiddlewaretoken'    # Django's CSRF token

    RESERVED_PARAMS = set((METHOD_PARAM, ACCEPT_PARAM, CONTENTTYPE_PARAM, CONTENT_PARAM, CSRF_PARAM))


    def __new__(cls, request, *args, **kwargs):
        """Make the class callable so it can be used as a Django view."""
        self = object.__new__(cls)
        self.__init__()
        return self._handle_request(request, *args, **kwargs)


    def __init__(self):
        pass


    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()


    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__)
   

    def available_content_types(self):
        """Return a list of strings of all the content-types that this resource can emit."""
        return [item[0] for item in self.emitters]


    def resp_status_text(self):
        """Return reason text corrosponding to our HTTP response status code.
        Provided for convienience."""
        return STATUS_CODE_TEXT.get(self.resp_status, '')


    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, **kwargs))


    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 read(self, headers={}, *args, **kwargs):
        """RESTful read on the resource, which must be subclassed to be implemented.  Should be a safe operation."""
        self.not_implemented('read')


    def create(self, data=None, headers={}, *args, **kwargs):
        """RESTful create on the resource, which must be subclassed to be implemented."""
        self.not_implemented('create')


    def update(self, data=None, headers={}, *args, **kwargs):
        """RESTful update on the resource, which must be subclassed to be implemented.  Should be an idempotent operation."""
        self.not_implemented('update')


    def delete(self, headers={}, *args, **kwargs):
        """RESTful delete on the resource, which must be subclassed to be implemented.  Should be an idempotent operation."""
        self.not_implemented('delete')


    def not_implemented(self, operation):
        """Return an HTTP 500 server error if an operation is called which has been allowed by
        allowed_operations, but which has not been implemented."""
        raise ResourceException(Status.HTTP_500_INTERNAL_SERVER_ERROR,
                                {'detail': '%s operation on this resource has not been implemented' % (operation, )})


    def determine_method(self, request):
        """Determine the HTTP method that this request should be treated as.
        Allow for PUT and DELETE tunneling via the _method parameter."""
        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):
        """TODO"""
        # user = ...
        # if DEBUG and request is from localhost
        # if anon_user and not anon_allowed_operations raise PermissionDenied
        # return 


    def check_method_allowed(self, method):
        """Ensure the request method is acceptable for this resource."""
        if not method in self.CALLMAP.keys():
            raise ResourceException(Status.HTTP_501_NOT_IMPLEMENTED,
                                    {'detail': 'Unknown or unsupported method \'%s\'' % method})
            
        if not self.CALLMAP[method] in self.allowed_operations:
            raise ResourceException(Status.HTTP_405_METHOD_NOT_ALLOWED,
                                    {'detail': 'Method \'%s\' not allowed on this resource.' % method})


    def get_bound_form(self, data=None, is_response=False):
        """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.  is_response indicates if data should be
        treated as the input data (bind to client input) or the response data (bind to an existing object)."""
        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())

                # Add any non-field errors
                if form_instance.non_field_errors():
                    details['errors'] = self.form.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 ResourceException(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')
        split = content_type.split(';', 1)
        if len(split) > 1:
            content_type = split[0]
        content_type = content_type.strip()

        try:
            return self.parsers[content_type]
        except KeyError:
            raise ResourceException(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"""
        default = self.emitters[0]

        if self.ACCEPT_PARAM and request.GET.get(self.ACCEPT_PARAM, None):
            # Use _accept parameter override
            accept_list = [(request.GET.get(self.ACCEPT_PARAM),)]
        elif request.META.has_key('HTTP_ACCEPT'):
            # Use standard HTTP Accept negotiation
            accept_list = [item.split(';') for item in request.META["HTTP_ACCEPT"].split(',')]
        else:
            # No accept header specified
            return default
        
        # Parse the accept header into a dict of {Priority: List of Mimetypes}
        accept_dict = {}    
        for item in accept_list:
            mimetype = item[0].strip()
            qvalue = Decimal('1.0')
            
            if len(item) > 1:
                # Parse items that have a qvalue eg text/html;q=0.9
                try:
                    (q, num) = item[1].split('=')
                    if q == 'q':
                        qvalue = Decimal(num)
                except:
                    # Skip malformed entries
                    continue

            if accept_dict.has_key(qvalue):
                accept_dict[qvalue].append(mimetype)
            else:
                accept_dict[qvalue] = [mimetype]
        
        # Go through all accepted mimetypes in priority order and return our first match
        qvalues = accept_dict.keys()
        qvalues.sort(reverse=True)
       
        for qvalue in qvalues:
            for (mimetype, emitter) in self.emitters:
                for accept_mimetype in accept_dict[qvalue]:
                    if ((accept_mimetype == '*/*') or
                        (accept_mimetype.endswith('/*') and mimetype.startswith(accept_mimetype[:-1])) or
                        (accept_mimetype == mimetype)):
                            return (mimetype, emitter)      

        raise ResourceException(Status.HTTP_406_NOT_ACCEPTABLE,
                                {'detail': 'Could not statisfy the client\'s accepted content type',
                                 'accepted_types': [item[0] for item in self.emitters]})


    def _handle_request(self, request, *args, **kwargs):
        """
        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)

        # We make these attributes to allow for a certain amount of munging,
        # eg The HTML emitter needs to render this information
        self.request = request
        self.form_instance = None
        self.resp_status = None
        self.resp_headers = {}

        try:
            # Before we attempt anything else determine what format to emit our response data with.
            mimetype, emitter = self.determine_emitter(request)

            # Ensure the requested operation is permitted on this resource
            self.check_method_allowed(method)

            # Get the appropriate create/read/update/delete function
            func = getattr(self, self.CALLMAP.get(method, ''))
    
            # Either generate the response data, deserializing and validating any request data
            if method in ('PUT', 'POST'):
                parser = self.determine_parser(request)
                data = parser(self).parse(request.raw_post_data)
                self.form_instance = self.get_bound_form(data)
                data = self.cleanup_request(data, self.form_instance)
                (self.resp_status, ret, self.resp_headers) = func(data, request.META, *args, **kwargs)

            else:
                (self.resp_status, ret, self.resp_headers) = func(request.META, *args, **kwargs)
                if emitter.uses_forms:
                    self.form_instance = self.get_bound_form(ret, is_response=True)


        except ResourceException, exc:
            # On exceptions we still serialize the response appropriately
            (self.resp_status, ret, self.resp_headers) = (exc.status, exc.content, exc.headers)

            # Fall back to the default emitter if we failed to perform content negotiation
            if emitter is None:
                mimetype, emitter = self.emitters[0]

            # Provide an empty bound form if we do not have an existing form and if one is required
            if self.form_instance is None and emitter.uses_forms:
                self.form_instance = self.get_bound_form()


        # Always add the allow header
        self.resp_headers['Allow'] = ', '.join([self.REVERSE_CALLMAP[operation] for operation in self.allowed_operations])
            
        # Serialize the response content
        ret = self.cleanup_response(ret)
        content = emitter(self).emit(ret)

        # Build the HTTP Response
        resp = HttpResponse(content, mimetype=mimetype, status=self.resp_status)
        for (key, val) in self.resp_headers.items():
            resp[key] = val

        return resp