diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 90c759708..d99b6f15b 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -14,7 +14,7 @@ from djangorestframework.parsers import FormParser, MultiPartParser from djangorestframework.resources import Resource from djangorestframework.response import Response, ErrorResponse from djangorestframework.utils import as_tuple, MSIE_USER_AGENT_REGEX -from djangorestframework.utils.mediatypes import is_form_media_type +from djangorestframework.utils.mediatypes import is_form_media_type, order_by_precedence from decimal import Decimal import re @@ -206,7 +206,7 @@ class RequestMixin(object): @property def _default_parser(self): """ - Return the view's default parser. + Return the view's default parser class. """ return self.parsers[0] @@ -245,15 +245,15 @@ class ResponseMixin(object): try: renderer = self._determine_renderer(self.request) except ErrorResponse, exc: - renderer = self._default_renderer + renderer = self._default_renderer(self) response = exc.response # Serialize the response content # TODO: renderer.media_type isn't the right thing to do here... if response.has_content_body: - content = renderer(self).render(response.cleaned_content, renderer.media_type) + content = renderer.render(response.cleaned_content, renderer.media_type) else: - content = renderer(self).render() + content = renderer.render() # Build the HTTP Response # TODO: renderer.media_type isn't the right thing to do here... @@ -264,10 +264,6 @@ class ResponseMixin(object): return resp - # TODO: This should be simpler now. - # Add a handles_response() to the renderer, then iterate through the - # acceptable media types, ordered by how specific they are, - # calling handles_response on each renderer. def _determine_renderer(self, request): """ Return the appropriate renderer for the output, given the client's 'Accept' header, @@ -282,60 +278,33 @@ class ResponseMixin(object): elif (self._IGNORE_IE_ACCEPT_HEADER and request.META.has_key('HTTP_USER_AGENT') and MSIE_USER_AGENT_REGEX.match(request.META['HTTP_USER_AGENT'])): + # Ignore MSIE's broken accept behavior and do something sensible instead accept_list = ['text/html', '*/*'] elif request.META.has_key('HTTP_ACCEPT'): # Use standard HTTP Accept negotiation - accept_list = request.META["HTTP_ACCEPT"].split(',') + accept_list = [token.strip() for token in request.META["HTTP_ACCEPT"].split(',')] else: # No accept header specified - return self._default_renderer - - # 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 + return self._default_renderer(self) - 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)] + # Check the acceptable media types against each renderer, + # attempting more specific media types first + # NB. The inner loop here isn't as bad as it first looks :) + # We're effectivly looping over max len(accept_list) * len(self.renderers) + renderers = [renderer_cls(self) for renderer_cls in self.renderers] + + for media_type_lst in order_by_precedence(accept_list): + for renderer in renderers: + for media_type in media_type_lst: + if renderer.can_handle_response(media_type): + return renderer - for accept_set in accept_sets: - # Return any exact match - for renderer in self.renderers: - if renderer.media_type in accept_set: - return renderer - - # Return any subtype match - for renderer in self.renderers: - if renderer.media_type.split('/')[0] + '/*' in accept_set: - return renderer - - # Return default - if '*/*' in accept_set: - return self._default_renderer - - + # No acceptable renderers were found raise ErrorResponse(status.HTTP_406_NOT_ACCEPTABLE, {'detail': 'Could not satisfy the client\'s Accept header', 'available_types': self._rendered_media_types}) + @property def _rendered_media_types(self): """ @@ -346,7 +315,7 @@ class ResponseMixin(object): @property def _default_renderer(self): """ - Return the view's default renderer. + Return the view's default renderer class. """ return self.renderers[0] diff --git a/djangorestframework/parsers.py b/djangorestframework/parsers.py index 7c76bcc69..726e09e97 100644 --- a/djangorestframework/parsers.py +++ b/djangorestframework/parsers.py @@ -54,7 +54,7 @@ class BaseParser(object): This may be overridden to provide for other behavior, but typically you'll instead want to just set the :attr:`media_type` attribute on the class. """ - return media_type_matches(content_type, self.media_type) + return media_type_matches(self.media_type, content_type) def parse(self, stream): """ diff --git a/djangorestframework/renderers.py b/djangorestframework/renderers.py index 3e59511c3..245bfdfea 100644 --- a/djangorestframework/renderers.py +++ b/djangorestframework/renderers.py @@ -16,7 +16,7 @@ from djangorestframework.compat import apply_markdown from djangorestframework.utils import dict2xml, url_resolves from djangorestframework.utils.breadcrumbs import get_breadcrumbs from djangorestframework.utils.description import get_name, get_description -from djangorestframework.utils.mediatypes import get_media_type_params, add_media_type_param +from djangorestframework.utils.mediatypes import get_media_type_params, add_media_type_param, media_type_matches from decimal import Decimal import re @@ -39,11 +39,26 @@ class BaseRenderer(object): All renderers must extend this class, set the :attr:`media_type` attribute, and override the :meth:`render` method. """ + media_type = None def __init__(self, view): self.view = view + def can_handle_response(self, accept): + """ + Returns :const:`True` if this renderer is able to deal with the given + *accept* media type. + + The default implementation for this function is to check the *accept* + argument against the :attr:`media_type` attribute set on the class to see if + they match. + + This may be overridden to provide for other behavior, but typically you'll + instead want to just set the :attr:`media_type` attribute on the class. + """ + return media_type_matches(self.media_type, accept) + def render(self, obj=None, media_type=None): """ Given an object render it into a string. @@ -66,9 +81,13 @@ class JSONRenderer(BaseRenderer): """ Renderer which serializes to JSON """ + media_type = 'application/json' def render(self, obj=None, media_type=None): + """ + Renders *obj* into serialized JSON. + """ if obj is None: return '' @@ -92,6 +111,9 @@ class XMLRenderer(BaseRenderer): media_type = 'application/xml' def render(self, obj=None, media_type=None): + """ + Renders *obj* into serialized XML. + """ if obj is None: return '' return dict2xml(obj) @@ -103,17 +125,22 @@ class TemplateRenderer(BaseRenderer): Render the object simply by using the given template. To create a template renderer, subclass this class, and set - the :attr:`media_type` and `:attr:template` attributes. + the :attr:`media_type` and :attr:`template` attributes. """ + media_type = None template = None def render(self, obj=None, media_type=None): + """ + Renders *obj* using the :attr:`template` specified on the class. + """ if obj is None: return '' - context = RequestContext(self.request, obj) - return self.template.render(context) + template = loader.get_template(self.template) + context = RequestContext(self.view.request, {'object': obj}) + return template.render(context) class DocumentingTemplateRenderer(BaseRenderer): @@ -121,6 +148,7 @@ class DocumentingTemplateRenderer(BaseRenderer): Base class for renderers used to self-document the API. Implementing classes should extend this class and set the template attribute. """ + template = None def _get_content(self, view, request, obj, media_type): @@ -215,6 +243,12 @@ class DocumentingTemplateRenderer(BaseRenderer): def render(self, obj=None, media_type=None): + """ + Renders *obj* using the :attr:`template` set on the class. + + The context used in the template contains all the information + needed to self-document the response to this request. + """ content = self._get_content(self.view, self.view.request, obj, media_type) form_instance = self._get_form_instance(self.view) @@ -272,6 +306,7 @@ class DocumentingHTMLRenderer(DocumentingTemplateRenderer): Renderer which provides a browsable HTML interface for an API. See the examples at http://api.django-rest-framework.org to see this in action. """ + media_type = 'text/html' template = 'renderer.html' @@ -282,6 +317,7 @@ class DocumentingXHTMLRenderer(DocumentingTemplateRenderer): We need this to be listed in preference to xml in order to return HTML to WebKit based browsers, given their Accept headers. """ + media_type = 'application/xhtml+xml' template = 'renderer.html' @@ -292,6 +328,7 @@ class DocumentingPlainTextRenderer(DocumentingTemplateRenderer): documentation of the returned status and headers, and of the resource's name and description. Useful for browsing an API with command line tools. """ + media_type = 'text/plain' template = 'renderer.txt' diff --git a/djangorestframework/templatetags/add_query_param.py b/djangorestframework/templatetags/add_query_param.py index 91c1a312b..94833bced 100644 --- a/djangorestframework/templatetags/add_query_param.py +++ b/djangorestframework/templatetags/add_query_param.py @@ -4,7 +4,7 @@ from urllib import quote register = Library() def add_query_param(url, param): - (key, val) = param.split('=') + (key, sep, val) = param.partition('=') param = '%s=%s' % (key, quote(val)) (scheme, netloc, path, params, query, fragment) = urlparse(url) if query: diff --git a/djangorestframework/tests/renderers.py b/djangorestframework/tests/renderers.py index 5364cd2e9..542769931 100644 --- a/djangorestframework/tests/renderers.py +++ b/djangorestframework/tests/renderers.py @@ -13,23 +13,24 @@ DUMMYCONTENT = 'dummycontent' RENDERER_A_SERIALIZER = lambda x: 'Renderer A: %s' % x RENDERER_B_SERIALIZER = lambda x: 'Renderer B: %s' % x -class MockView(ResponseMixin, DjangoView): - def get(self, request): - response = Response(DUMMYSTATUS, DUMMYCONTENT) - return self.render(response) - class RendererA(BaseRenderer): media_type = 'mock/renderera' - def render(self, obj=None, content_type=None): + def render(self, obj=None, media_type=None): return RENDERER_A_SERIALIZER(obj) class RendererB(BaseRenderer): media_type = 'mock/rendererb' - def render(self, obj=None, content_type=None): + def render(self, obj=None, media_type=None): return RENDERER_B_SERIALIZER(obj) +class MockView(ResponseMixin, DjangoView): + renderers = (RendererA, RendererB) + + def get(self, request): + response = Response(DUMMYSTATUS, DUMMYCONTENT) + return self.render(response) urlpatterns = patterns('', url(r'^$', MockView.as_view(renderers=[RendererA, RendererB])), diff --git a/djangorestframework/utils/__init__.py b/djangorestframework/utils/__init__.py index 67870001f..99f9724ce 100644 --- a/djangorestframework/utils/__init__.py +++ b/djangorestframework/utils/__init__.py @@ -13,6 +13,9 @@ import xml.etree.ElementTree as ET # """Adds the ADMIN_MEDIA_PREFIX to the request context.""" # return {'ADMIN_MEDIA_PREFIX': settings.ADMIN_MEDIA_PREFIX} +from mediatypes import media_type_matches, is_form_media_type +from mediatypes import add_media_type_param, get_media_type_params, order_by_precedence + MSIE_USER_AGENT_REGEX = re.compile(r'^Mozilla/[0-9]+\.[0-9]+ \([^)]*; MSIE [0-9]+\.[0-9]+[a-z]?;[^)]*\)(?!.* Opera )') def as_tuple(obj): diff --git a/djangorestframework/utils/mediatypes.py b/djangorestframework/utils/mediatypes.py index 190cdc2df..ae734e62d 100644 --- a/djangorestframework/utils/mediatypes.py +++ b/djangorestframework/utils/mediatypes.py @@ -51,6 +51,22 @@ def get_media_type_params(media_type): return _MediaType(media_type).params +def order_by_precedence(media_type_lst): + """ + Returns a list of lists of media type strings, ordered by precedence. + Precedence is determined by how specific a media type is: + + 3. 'type/subtype; param=val' + 2. 'type/subtype' + 1. 'type/*' + 0. '*/*' + """ + ret = [[],[],[],[]] + for media_type in media_type_lst: + precedence = _MediaType(media_type).precedence + ret[3-precedence].append(media_type) + return ret + class _MediaType(object): def __init__(self, media_type_str): @@ -61,53 +77,54 @@ class _MediaType(object): self.main_type, sep, self.sub_type = self.full_type.partition('/') def match(self, other): - """Return true if this MediaType satisfies the constraint of the given MediaType.""" - for key in other.params.keys(): - if key != 'q' and other.params[key] != self.params.get(key, None): + """Return true if this MediaType satisfies the given MediaType.""" + for key in self.params.keys(): + if key != 'q' and other.params.get(key, None) != self.params.get(key, None): return False - if other.sub_type != '*' and other.sub_type != self.sub_type: + if self.sub_type != '*' and other.sub_type != '*' and other.sub_type != self.sub_type: return False - if other.main_type != '*' and other.main_type != self.main_type: + if self.main_type != '*' and other.main_type != '*' and other.main_type != self.main_type: return False return True + @property def precedence(self): """ - Return a precedence level for the media type given how specific it is. + Return a precedence level from 0-3 for the media type given how specific it is. """ if self.main_type == '*': - return 1 + return 0 elif self.sub_type == '*': - return 2 + return 1 elif not self.params or self.params.keys() == ['q']: - return 3 - return 4 + return 2 + return 3 - def quality(self): - """ - Return a quality level for the media type. - """ - try: - return Decimal(self.params.get('q', '1.0')) - except: - return Decimal(0) + #def quality(self): + # """ + # Return a quality level for the media type. + # """ + # try: + # return Decimal(self.params.get('q', '1.0')) + # except: + # return Decimal(0) + + #def score(self): + # """ + # Return an overall score for a given media type given it's quality and precedence. + # """ + # # NB. quality values should only have up to 3 decimal points + # # http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.9 + # return self.quality * 10000 + self.precedence - def score(self): - """ - Return an overall score for a given media type given it's quality and precedence. - """ - # NB. quality values should only have up to 3 decimal points - # http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.9 - return self.quality * 10000 + self.precedence - - def as_tuple(self): - return (self.main_type, self.sub_type, self.params) + #def as_tuple(self): + # return (self.main_type, self.sub_type, self.params) - def __repr__(self): - return "" % (self.as_tuple(),) + #def __repr__(self): + # return "" % (self.as_tuple(),) def __str__(self): return unicode(self).encode('utf-8') diff --git a/djangorestframework/views.py b/djangorestframework/views.py index 3d6a6c403..ade90cac2 100644 --- a/djangorestframework/views.py +++ b/djangorestframework/views.py @@ -99,52 +99,58 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): # all other authentication is CSRF exempt. @csrf_exempt def dispatch(self, request, *args, **kwargs): - self.request = request - self.args = args - self.kwargs = kwargs - - # Calls to 'reverse' will not be fully qualified unless we set the scheme/host/port here. - prefix = '%s://%s' % (request.is_secure() and 'https' or 'http', request.get_host()) - set_script_prefix(prefix) - try: - self.initial(request, *args, **kwargs) - - # Authenticate and check request has the relevant permissions - self._check_permissions() - - # Get the appropriate handler method - if self.method.lower() in self.http_method_names: - handler = getattr(self, self.method.lower(), self.http_method_not_allowed) - else: - handler = self.http_method_not_allowed - - response_obj = handler(request, *args, **kwargs) - - # Allow return value to be either HttpResponse, Response, or an object, or None - if isinstance(response_obj, HttpResponse): - return response_obj - elif isinstance(response_obj, Response): - response = response_obj - elif response_obj is not None: - response = Response(status.HTTP_200_OK, response_obj) - else: - response = Response(status.HTTP_204_NO_CONTENT) - - # Pre-serialize filtering (eg filter complex objects into natively serializable types) - response.cleaned_content = self.filter_response(response.raw_content) + self.request = request + self.args = args + self.kwargs = kwargs - except ErrorResponse, exc: - response = exc.response - - # Always add these headers. - # - # TODO - this isn't actually the correct way to set the vary header, - # also it's currently sub-obtimal for HTTP caching - need to sort that out. - response.headers['Allow'] = ', '.join(self.allowed_methods) - response.headers['Vary'] = 'Authenticate, Accept' - - return self.render(response) + # Calls to 'reverse' will not be fully qualified unless we set the scheme/host/port here. + prefix = '%s://%s' % (request.is_secure() and 'https' or 'http', request.get_host()) + set_script_prefix(prefix) + + try: + self.initial(request, *args, **kwargs) + + # Authenticate and check request has the relevant permissions + self._check_permissions() + + # Get the appropriate handler method + if self.method.lower() in self.http_method_names: + handler = getattr(self, self.method.lower(), self.http_method_not_allowed) + else: + handler = self.http_method_not_allowed + + response_obj = handler(request, *args, **kwargs) + + # Allow return value to be either HttpResponse, Response, or an object, or None + if isinstance(response_obj, HttpResponse): + return response_obj + elif isinstance(response_obj, Response): + response = response_obj + elif response_obj is not None: + response = Response(status.HTTP_200_OK, response_obj) + else: + response = Response(status.HTTP_204_NO_CONTENT) + + # Pre-serialize filtering (eg filter complex objects into natively serializable types) + response.cleaned_content = self.filter_response(response.raw_content) + + except ErrorResponse, exc: + response = exc.response + + # Always add these headers. + # + # TODO - this isn't actually the correct way to set the vary header, + # also it's currently sub-obtimal for HTTP caching - need to sort that out. + response.headers['Allow'] = ', '.join(self.allowed_methods) + response.headers['Vary'] = 'Authenticate, Accept' + + return self.render(response) + + except: + import traceback + traceback.print_exc() + raise