Renderers can now cope with parameterised args. ResponseMixin gets cleaned up & added Renderer.can_handle_response(), mirroring Parsers.can_handle_request()

This commit is contained in:
Tom Christie 2011-05-24 13:29:30 +01:00
parent eafda85508
commit ce6e5fdc01
8 changed files with 173 additions and 140 deletions

View File

@ -14,7 +14,7 @@ from djangorestframework.parsers import FormParser, MultiPartParser
from djangorestframework.resources import Resource from djangorestframework.resources import Resource
from djangorestframework.response import Response, ErrorResponse from djangorestframework.response import Response, ErrorResponse
from djangorestframework.utils import as_tuple, MSIE_USER_AGENT_REGEX 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 from decimal import Decimal
import re import re
@ -206,7 +206,7 @@ class RequestMixin(object):
@property @property
def _default_parser(self): def _default_parser(self):
""" """
Return the view's default parser. Return the view's default parser class.
""" """
return self.parsers[0] return self.parsers[0]
@ -245,15 +245,15 @@ class ResponseMixin(object):
try: try:
renderer = self._determine_renderer(self.request) renderer = self._determine_renderer(self.request)
except ErrorResponse, exc: except ErrorResponse, exc:
renderer = self._default_renderer renderer = self._default_renderer(self)
response = exc.response response = exc.response
# Serialize the response content # Serialize the response content
# TODO: renderer.media_type isn't the right thing to do here... # TODO: renderer.media_type isn't the right thing to do here...
if response.has_content_body: 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: else:
content = renderer(self).render() content = renderer.render()
# Build the HTTP Response # Build the HTTP Response
# TODO: renderer.media_type isn't the right thing to do here... # TODO: renderer.media_type isn't the right thing to do here...
@ -264,10 +264,6 @@ class ResponseMixin(object):
return resp 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): def _determine_renderer(self, request):
""" """
Return the appropriate renderer for the output, given the client's 'Accept' header, 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 elif (self._IGNORE_IE_ACCEPT_HEADER and
request.META.has_key('HTTP_USER_AGENT') and request.META.has_key('HTTP_USER_AGENT') and
MSIE_USER_AGENT_REGEX.match(request.META['HTTP_USER_AGENT'])): 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', '*/*'] accept_list = ['text/html', '*/*']
elif request.META.has_key('HTTP_ACCEPT'): elif request.META.has_key('HTTP_ACCEPT'):
# Use standard HTTP Accept negotiation # 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: else:
# No accept header specified # No accept header specified
return self._default_renderer return self._default_renderer(self)
# 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): # Check the acceptable media types against each renderer,
accept_dict[qvalue].add(mimetype) # attempting more specific media types first
else: # NB. The inner loop here isn't as bad as it first looks :)
accept_dict[qvalue] = set((mimetype,)) # We're effectivly looping over max len(accept_list) * len(self.renderers)
renderers = [renderer_cls(self) for renderer_cls in self.renderers]
# 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 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: # No acceptable renderers were found
# 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
raise ErrorResponse(status.HTTP_406_NOT_ACCEPTABLE, raise ErrorResponse(status.HTTP_406_NOT_ACCEPTABLE,
{'detail': 'Could not satisfy the client\'s Accept header', {'detail': 'Could not satisfy the client\'s Accept header',
'available_types': self._rendered_media_types}) 'available_types': self._rendered_media_types})
@property @property
def _rendered_media_types(self): def _rendered_media_types(self):
""" """
@ -346,7 +315,7 @@ class ResponseMixin(object):
@property @property
def _default_renderer(self): def _default_renderer(self):
""" """
Return the view's default renderer. Return the view's default renderer class.
""" """
return self.renderers[0] return self.renderers[0]

View File

@ -54,7 +54,7 @@ class BaseParser(object):
This may be overridden to provide for other behavior, but typically you'll 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. 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): def parse(self, stream):
""" """

View File

@ -16,7 +16,7 @@ from djangorestframework.compat import apply_markdown
from djangorestframework.utils import dict2xml, url_resolves from djangorestframework.utils import dict2xml, url_resolves
from djangorestframework.utils.breadcrumbs import get_breadcrumbs from djangorestframework.utils.breadcrumbs import get_breadcrumbs
from djangorestframework.utils.description import get_name, get_description 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 from decimal import Decimal
import re import re
@ -39,11 +39,26 @@ class BaseRenderer(object):
All renderers must extend this class, set the :attr:`media_type` attribute, All renderers must extend this class, set the :attr:`media_type` attribute,
and override the :meth:`render` method. and override the :meth:`render` method.
""" """
media_type = None media_type = None
def __init__(self, view): def __init__(self, view):
self.view = 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): def render(self, obj=None, media_type=None):
""" """
Given an object render it into a string. Given an object render it into a string.
@ -66,9 +81,13 @@ class JSONRenderer(BaseRenderer):
""" """
Renderer which serializes to JSON Renderer which serializes to JSON
""" """
media_type = 'application/json' media_type = 'application/json'
def render(self, obj=None, media_type=None): def render(self, obj=None, media_type=None):
"""
Renders *obj* into serialized JSON.
"""
if obj is None: if obj is None:
return '' return ''
@ -92,6 +111,9 @@ class XMLRenderer(BaseRenderer):
media_type = 'application/xml' media_type = 'application/xml'
def render(self, obj=None, media_type=None): def render(self, obj=None, media_type=None):
"""
Renders *obj* into serialized XML.
"""
if obj is None: if obj is None:
return '' return ''
return dict2xml(obj) return dict2xml(obj)
@ -103,17 +125,22 @@ class TemplateRenderer(BaseRenderer):
Render the object simply by using the given template. Render the object simply by using the given template.
To create a template renderer, subclass this class, and set 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 media_type = None
template = None template = None
def render(self, obj=None, media_type=None): def render(self, obj=None, media_type=None):
"""
Renders *obj* using the :attr:`template` specified on the class.
"""
if obj is None: if obj is None:
return '' return ''
context = RequestContext(self.request, obj) template = loader.get_template(self.template)
return self.template.render(context) context = RequestContext(self.view.request, {'object': obj})
return template.render(context)
class DocumentingTemplateRenderer(BaseRenderer): class DocumentingTemplateRenderer(BaseRenderer):
@ -121,6 +148,7 @@ class DocumentingTemplateRenderer(BaseRenderer):
Base class for renderers used to self-document the API. Base class for renderers used to self-document the API.
Implementing classes should extend this class and set the template attribute. Implementing classes should extend this class and set the template attribute.
""" """
template = None template = None
def _get_content(self, view, request, obj, media_type): def _get_content(self, view, request, obj, media_type):
@ -215,6 +243,12 @@ class DocumentingTemplateRenderer(BaseRenderer):
def render(self, obj=None, media_type=None): 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) content = self._get_content(self.view, self.view.request, obj, media_type)
form_instance = self._get_form_instance(self.view) 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. 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. See the examples at http://api.django-rest-framework.org to see this in action.
""" """
media_type = 'text/html' media_type = 'text/html'
template = 'renderer.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, We need this to be listed in preference to xml in order to return HTML to WebKit based browsers,
given their Accept headers. given their Accept headers.
""" """
media_type = 'application/xhtml+xml' media_type = 'application/xhtml+xml'
template = 'renderer.html' 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. documentation of the returned status and headers, and of the resource's name and description.
Useful for browsing an API with command line tools. Useful for browsing an API with command line tools.
""" """
media_type = 'text/plain' media_type = 'text/plain'
template = 'renderer.txt' template = 'renderer.txt'

View File

@ -4,7 +4,7 @@ from urllib import quote
register = Library() register = Library()
def add_query_param(url, param): def add_query_param(url, param):
(key, val) = param.split('=') (key, sep, val) = param.partition('=')
param = '%s=%s' % (key, quote(val)) param = '%s=%s' % (key, quote(val))
(scheme, netloc, path, params, query, fragment) = urlparse(url) (scheme, netloc, path, params, query, fragment) = urlparse(url)
if query: if query:

View File

@ -13,23 +13,24 @@ DUMMYCONTENT = 'dummycontent'
RENDERER_A_SERIALIZER = lambda x: 'Renderer A: %s' % x RENDERER_A_SERIALIZER = lambda x: 'Renderer A: %s' % x
RENDERER_B_SERIALIZER = lambda x: 'Renderer B: %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): class RendererA(BaseRenderer):
media_type = 'mock/renderera' 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) return RENDERER_A_SERIALIZER(obj)
class RendererB(BaseRenderer): class RendererB(BaseRenderer):
media_type = 'mock/rendererb' 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) 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('', urlpatterns = patterns('',
url(r'^$', MockView.as_view(renderers=[RendererA, RendererB])), url(r'^$', MockView.as_view(renderers=[RendererA, RendererB])),

View File

@ -13,6 +13,9 @@ import xml.etree.ElementTree as ET
# """Adds the ADMIN_MEDIA_PREFIX to the request context.""" # """Adds the ADMIN_MEDIA_PREFIX to the request context."""
# return {'ADMIN_MEDIA_PREFIX': settings.ADMIN_MEDIA_PREFIX} # 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 )') MSIE_USER_AGENT_REGEX = re.compile(r'^Mozilla/[0-9]+\.[0-9]+ \([^)]*; MSIE [0-9]+\.[0-9]+[a-z]?;[^)]*\)(?!.* Opera )')
def as_tuple(obj): def as_tuple(obj):

View File

@ -51,6 +51,22 @@ def get_media_type_params(media_type):
return _MediaType(media_type).params 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): class _MediaType(object):
def __init__(self, media_type_str): def __init__(self, media_type_str):
@ -61,53 +77,54 @@ class _MediaType(object):
self.main_type, sep, self.sub_type = self.full_type.partition('/') self.main_type, sep, self.sub_type = self.full_type.partition('/')
def match(self, other): def match(self, other):
"""Return true if this MediaType satisfies the constraint of the given MediaType.""" """Return true if this MediaType satisfies the given MediaType."""
for key in other.params.keys(): for key in self.params.keys():
if key != 'q' and other.params[key] != self.params.get(key, None): if key != 'q' and other.params.get(key, None) != self.params.get(key, None):
return False 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 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 False
return True return True
@property
def precedence(self): 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 == '*': if self.main_type == '*':
return 1 return 0
elif self.sub_type == '*': elif self.sub_type == '*':
return 2 return 1
elif not self.params or self.params.keys() == ['q']: elif not self.params or self.params.keys() == ['q']:
return 3 return 2
return 4 return 3
def quality(self): #def quality(self):
""" # """
Return a quality level for the media type. # Return a quality level for the media type.
""" # """
try: # try:
return Decimal(self.params.get('q', '1.0')) # return Decimal(self.params.get('q', '1.0'))
except: # except:
return Decimal(0) # 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): #def as_tuple(self):
""" # return (self.main_type, self.sub_type, self.params)
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 __repr__(self): #def __repr__(self):
return "<MediaType %s>" % (self.as_tuple(),) # return "<MediaType %s>" % (self.as_tuple(),)
def __str__(self): def __str__(self):
return unicode(self).encode('utf-8') return unicode(self).encode('utf-8')

View File

@ -99,52 +99,58 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView):
# all other authentication is CSRF exempt. # all other authentication is CSRF exempt.
@csrf_exempt @csrf_exempt
def dispatch(self, request, *args, **kwargs): 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: try:
self.initial(request, *args, **kwargs) self.request = request
self.args = args
# Authenticate and check request has the relevant permissions self.kwargs = kwargs
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: # Calls to 'reverse' will not be fully qualified unless we set the scheme/host/port here.
response = exc.response prefix = '%s://%s' % (request.is_secure() and 'https' or 'http', request.get_host())
set_script_prefix(prefix)
# Always add these headers.
# try:
# TODO - this isn't actually the correct way to set the vary header, self.initial(request, *args, **kwargs)
# also it's currently sub-obtimal for HTTP caching - need to sort that out.
response.headers['Allow'] = ', '.join(self.allowed_methods) # Authenticate and check request has the relevant permissions
response.headers['Vary'] = 'Authenticate, Accept' self._check_permissions()
return self.render(response) # 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