diff options
| author | tom christie tom@tomchristie.com | 2011-01-23 23:08:44 +0000 |
|---|---|---|
| committer | tom christie tom@tomchristie.com | 2011-01-23 23:08:44 +0000 |
| commit | e95198a1c0b206bd3b565bb62d167ada71595099 (patch) | |
| tree | 65dc7f469b28f09783b732862ab9822b8528f10d /src/rest/resource.py | |
| parent | 4100242fa2395bef8db0c5ffbab6f5d0cf95301d (diff) | |
| download | django-rest-framework-e95198a1c0b206bd3b565bb62d167ada71595099.tar.bz2 | |
Sphinx docs, examples, lots of refactoring
Diffstat (limited to 'src/rest/resource.py')
| -rw-r--r-- | src/rest/resource.py | 382 |
1 files changed, 0 insertions, 382 deletions
diff --git a/src/rest/resource.py b/src/rest/resource.py deleted file mode 100644 index b94854f5..00000000 --- a/src/rest/resource.py +++ /dev/null @@ -1,382 +0,0 @@ -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 - |
