diff options
| author | tom christie tom@tomchristie.com | 2011-01-23 23:08:16 +0000 |
|---|---|---|
| committer | tom christie tom@tomchristie.com | 2011-01-23 23:08:16 +0000 |
| commit | 4100242fa2395bef8db0c5ffbab6f5d0cf95301d (patch) | |
| tree | 0c8efd4dfdbdc6af7dca234291f8f8c7e3b035ff /flywheel/resource.py | |
| parent | 99799032721a32220c32d4a74a950bdd07b13cb3 (diff) | |
| download | django-rest-framework-4100242fa2395bef8db0c5ffbab6f5d0cf95301d.tar.bz2 | |
Sphinx docs, examples, lots of refactoring
Diffstat (limited to 'flywheel/resource.py')
| -rw-r--r-- | flywheel/resource.py | 436 |
1 files changed, 436 insertions, 0 deletions
diff --git a/flywheel/resource.py b/flywheel/resource.py new file mode 100644 index 00000000..1b76b98a --- /dev/null +++ b/flywheel/resource.py @@ -0,0 +1,436 @@ +from django.contrib.sites.models import Site +from django.core.urlresolvers import reverse +from django.http import HttpResponse + +from flywheel import emitters, parsers +from flywheel.response import status, Response, ResponseException + +from decimal import Decimal +import re +from itertools import chain + +# TODO: Authentication +# TODO: Display user login in top panel: http://stackoverflow.com/questions/806835/django-redirect-to-previous-page-after-login +# TODO: Figure how out references and named urls need to work nicely +# TODO: POST on existing 404 URL, PUT on existing 404 URL +# TODO: Remove is_error throughout +# +# NEXT: Exceptions on func() -> 500, tracebacks emitted if settings.DEBUG +# 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 + +__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 ) + + # 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, request, *args, **kwargs): + """Make the class callable so it can be used as a Django view.""" + self = object.__new__(cls) + self.__init__(request) + try: + return self._handle_request(request, *args, **kwargs) + except: + import traceback + traceback.print_exc() + raise + + + def __init__(self, request): + """""" + # Setup the resource context + self.request = request + self.auth_context = None + 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] + + # TODO: + + #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] + + #def deafult_parser(self): + # return self.parsers[0] + + + 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 authenticate(self, request): + """TODO""" + return None + # user = ... + # if DEBUG and request is from localhost + # if anon_user and not anon_allowed_methods raise PermissionDenied + # return auth_context + + + def get(self, request, *args, **kwargs): + """Must be subclassed to be implemented.""" + self.not_implemented('GET') + + + def post(self, request, content, *args, **kwargs): + """Must be subclassed to be implemented.""" + self.not_implemented('POST') + + + def put(self, request, content, *args, **kwargs): + """Must be subclassed to be implemented.""" + self.not_implemented('PUT') + + + def delete(self, request, *args, **kwargs): + """Must be subclassed to be implemented.""" + 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_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 check_method_allowed(self, method): + """Ensure the request method is acceptable for this resource.""" + 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}) + + + 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. 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'] = 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') + split = content_type.split(';', 1) + if len(split) > 1: + content_type = split[0] + content_type = content_type.strip() + + # Create a list of list of (media_type, Parser) tuples + media_type_parser_tuples = [[(media_type, parser) for media_type in parser.media_types] for parser in self.parsers] + + # Flatten the list and turn it into a media_type -> Parser dict + media_type_to_parser = dict(chain.from_iterable(media_type_parser_tuples)) + + try: + return media_type_to_parser[content_type] + 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): + """ + 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) + self.auth_context = self.authenticate(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, 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 = self.determine_parser(request) + data = parser(self).parse(request.raw_post_data) + self.form_instance = self.get_form(data) + data = self.cleanup_request(data, self.form_instance) + response = func(request, data, *args, **kwargs) + + else: + response = func(request, *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 + |
