Support for HTML error templates. Fixes #319.

This commit is contained in:
Tom Christie 2012-11-06 10:44:19 +00:00
parent 455a8cedcf
commit b19c58ae17
5 changed files with 129 additions and 10 deletions

View File

@ -257,6 +257,21 @@ In [the words of Roy Fielding][quote], "A REST API should spend almost all of it
For good examples of custom media types, see GitHub's use of a custom [application/vnd.github+json] media type, and Mike Amundsen's IANA approved [application/vnd.collection+json] JSON-based hypermedia. For good examples of custom media types, see GitHub's use of a custom [application/vnd.github+json] media type, and Mike Amundsen's IANA approved [application/vnd.collection+json] JSON-based hypermedia.
## HTML error views
Typically a renderer will behave the same regardless of if it's dealing with a regular response, or with a response caused by an exception being raised, such as an `Http404` or `PermissionDenied` exception, or a subclass of `APIException`.
If you're using either the `TemplateHTMLRenderer` or the `StaticHTMLRenderer` and an exception is raised, the behavior is slightly different, and mirrors [Django's default handling of error views][django-error-views].
Exceptions raised and handled by an HTML renderer will attempt to render using one of the following methods, by order of precedence.
* Load and render a template named `{status_code}.html`.
* Load and render a template named `api_exception.html`.
* Render the HTTP status code and text, for example "404 Not Found".
Templates will render with a `RequestContext` which includes the `status_code` and `details` keys.
[cite]: https://docs.djangoproject.com/en/dev/ref/template-response/#the-rendering-process [cite]: https://docs.djangoproject.com/en/dev/ref/template-response/#the-rendering-process
[conneg]: content-negotiation.md [conneg]: content-negotiation.md
[browser-accept-headers]: http://www.gethifi.com/blog/browser-rest-http-accept-headers [browser-accept-headers]: http://www.gethifi.com/blog/browser-rest-http-accept-headers
@ -265,3 +280,4 @@ For good examples of custom media types, see GitHub's use of a custom [applicati
[quote]: http://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven [quote]: http://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven
[application/vnd.github+json]: http://developer.github.com/v3/media/ [application/vnd.github+json]: http://developer.github.com/v3/media/
[application/vnd.collection+json]: http://www.amundsen.com/media-types/collection/ [application/vnd.collection+json]: http://www.amundsen.com/media-types/collection/
[django-error-views]: https://docs.djangoproject.com/en/dev/topics/http/views/#customizing-error-views

View File

@ -10,7 +10,7 @@ import copy
import string import string
from django import forms from django import forms
from django.http.multipartparser import parse_header from django.http.multipartparser import parse_header
from django.template import RequestContext, loader from django.template import RequestContext, loader, Template
from django.utils import simplejson as json from django.utils import simplejson as json
from rest_framework.compat import yaml from rest_framework.compat import yaml
from rest_framework.exceptions import ConfigurationError from rest_framework.exceptions import ConfigurationError
@ -162,6 +162,10 @@ class TemplateHTMLRenderer(BaseRenderer):
media_type = 'text/html' media_type = 'text/html'
format = 'html' format = 'html'
template_name = None template_name = None
exception_template_names = [
'%(status_code)s.html',
'api_exception.html'
]
def render(self, data, accepted_media_type=None, renderer_context=None): def render(self, data, accepted_media_type=None, renderer_context=None):
""" """
@ -178,15 +182,21 @@ class TemplateHTMLRenderer(BaseRenderer):
request = renderer_context['request'] request = renderer_context['request']
response = renderer_context['response'] response = renderer_context['response']
if response.exception:
template = self.get_exception_template(response)
else:
template_names = self.get_template_names(response, view) template_names = self.get_template_names(response, view)
template = self.resolve_template(template_names) template = self.resolve_template(template_names)
context = self.resolve_context(data, request)
context = self.resolve_context(data, request, response)
return template.render(context) return template.render(context)
def resolve_template(self, template_names): def resolve_template(self, template_names):
return loader.select_template(template_names) return loader.select_template(template_names)
def resolve_context(self, data, request): def resolve_context(self, data, request, response):
if response.exception:
data['status_code'] = response.status_code
return RequestContext(request, data) return RequestContext(request, data)
def get_template_names(self, response, view): def get_template_names(self, response, view):
@ -198,8 +208,21 @@ class TemplateHTMLRenderer(BaseRenderer):
return view.get_template_names() return view.get_template_names()
raise ConfigurationError('Returned a template response with no template_name') raise ConfigurationError('Returned a template response with no template_name')
def get_exception_template(self, response):
template_names = [name % {'status_code': response.status_code}
for name in self.exception_template_names]
class StaticHTMLRenderer(BaseRenderer): try:
# Try to find an appropriate error template
return self.resolve_template(template_names)
except:
# Fall back to using eg '404 Not Found'
return Template('%d %s' % (response.status_code,
response.status_text.title()))
# Note, subclass TemplateHTMLRenderer simply for the exception behavior
class StaticHTMLRenderer(TemplateHTMLRenderer):
""" """
An HTML renderer class that simply returns pre-rendered HTML. An HTML renderer class that simply returns pre-rendered HTML.
@ -216,6 +239,15 @@ class StaticHTMLRenderer(BaseRenderer):
format = 'html' format = 'html'
def render(self, data, accepted_media_type=None, renderer_context=None): def render(self, data, accepted_media_type=None, renderer_context=None):
renderer_context = renderer_context or {}
response = renderer_context['response']
if response and response.exception:
request = renderer_context['request']
template = self.get_exception_template(response)
context = self.resolve_context(data, request, response)
return template.render(context)
return data return data

View File

@ -9,7 +9,8 @@ class Response(SimpleTemplateResponse):
""" """
def __init__(self, data=None, status=200, def __init__(self, data=None, status=200,
template_name=None, headers=None): template_name=None, headers=None,
exception=False):
""" """
Alters the init arguments slightly. Alters the init arguments slightly.
For example, drop 'template_name', and instead use 'data'. For example, drop 'template_name', and instead use 'data'.
@ -21,6 +22,7 @@ class Response(SimpleTemplateResponse):
self.data = data self.data = data
self.headers = headers and headers[:] or [] self.headers = headers and headers[:] or []
self.template_name = template_name self.template_name = template_name
self.exception = exception
@property @property
def rendered_content(self): def rendered_content(self):

View File

@ -1,4 +1,6 @@
from django.core.exceptions import PermissionDenied
from django.conf.urls.defaults import patterns, url from django.conf.urls.defaults import patterns, url
from django.http import Http404
from django.test import TestCase from django.test import TestCase
from django.template import TemplateDoesNotExist, Template from django.template import TemplateDoesNotExist, Template
import django.template.loader import django.template.loader
@ -17,8 +19,22 @@ def example(request):
return Response(data, template_name='example.html') return Response(data, template_name='example.html')
@api_view(('GET',))
@renderer_classes((TemplateHTMLRenderer,))
def permission_denied(request):
raise PermissionDenied()
@api_view(('GET',))
@renderer_classes((TemplateHTMLRenderer,))
def not_found(request):
raise Http404()
urlpatterns = patterns('', urlpatterns = patterns('',
url(r'^$', example), url(r'^$', example),
url(r'^permission_denied$', permission_denied),
url(r'^not_found$', not_found),
) )
@ -48,3 +64,52 @@ class TemplateHTMLRendererTests(TestCase):
response = self.client.get('/') response = self.client.get('/')
self.assertContains(response, "example: foobar") self.assertContains(response, "example: foobar")
self.assertEquals(response['Content-Type'], 'text/html') self.assertEquals(response['Content-Type'], 'text/html')
def test_not_found_html_view(self):
response = self.client.get('/not_found')
self.assertEquals(response.status_code, 404)
self.assertEquals(response.content, "404 Not Found")
self.assertEquals(response['Content-Type'], 'text/html')
def test_permission_denied_html_view(self):
response = self.client.get('/permission_denied')
self.assertEquals(response.status_code, 403)
self.assertEquals(response.content, "403 Forbidden")
self.assertEquals(response['Content-Type'], 'text/html')
class TemplateHTMLRendererExceptionTests(TestCase):
urls = 'rest_framework.tests.htmlrenderer'
def setUp(self):
"""
Monkeypatch get_template
"""
self.get_template = django.template.loader.get_template
def get_template(template_name):
if template_name == '404.html':
return Template("404: {{ detail }}")
if template_name == '403.html':
return Template("403: {{ detail }}")
raise TemplateDoesNotExist(template_name)
django.template.loader.get_template = get_template
def tearDown(self):
"""
Revert monkeypatching
"""
django.template.loader.get_template = self.get_template
def test_not_found_html_view_with_template(self):
response = self.client.get('/not_found')
self.assertEquals(response.status_code, 404)
self.assertEquals(response.content, "404: Not found")
self.assertEquals(response['Content-Type'], 'text/html')
def test_permission_denied_html_view_with_template(self):
response = self.client.get('/permission_denied')
self.assertEquals(response.status_code, 403)
self.assertEquals(response.content, "403: Permission denied")
self.assertEquals(response['Content-Type'], 'text/html')

View File

@ -320,13 +320,17 @@ class APIView(View):
self.headers['X-Throttle-Wait-Seconds'] = '%d' % exc.wait self.headers['X-Throttle-Wait-Seconds'] = '%d' % exc.wait
if isinstance(exc, exceptions.APIException): if isinstance(exc, exceptions.APIException):
return Response({'detail': exc.detail}, status=exc.status_code) return Response({'detail': exc.detail},
status=exc.status_code,
exception=True)
elif isinstance(exc, Http404): elif isinstance(exc, Http404):
return Response({'detail': 'Not found'}, return Response({'detail': 'Not found'},
status=status.HTTP_404_NOT_FOUND) status=status.HTTP_404_NOT_FOUND,
exception=True)
elif isinstance(exc, PermissionDenied): elif isinstance(exc, PermissionDenied):
return Response({'detail': 'Permission denied'}, return Response({'detail': 'Permission denied'},
status=status.HTTP_403_FORBIDDEN) status=status.HTTP_403_FORBIDDEN,
exception=True)
raise raise
# Note: session based authentication is explicitly CSRF validated, # Note: session based authentication is explicitly CSRF validated,