mirror of
https://github.com/encode/django-rest-framework.git
synced 2025-02-03 05:04:31 +03:00
Merge pull request #263 from tomchristie/decouple-conneg
Content negotiation logic out of response and into View
This commit is contained in:
commit
549ebdc1c6
|
@ -31,6 +31,14 @@ class PermissionDenied(APIException):
|
|||
self.detail = detail or self.default_detail
|
||||
|
||||
|
||||
class InvalidFormat(APIException):
|
||||
status_code = status.HTTP_404_NOT_FOUND
|
||||
default_detail = "Format suffix '.%s' not found."
|
||||
|
||||
def __init__(self, format, detail=None):
|
||||
self.detail = (detail or self.default_detail) % format
|
||||
|
||||
|
||||
class MethodNotAllowed(APIException):
|
||||
status_code = status.HTTP_405_METHOD_NOT_ALLOWED
|
||||
default_detail = "Method '%s' not allowed."
|
||||
|
@ -39,6 +47,15 @@ class MethodNotAllowed(APIException):
|
|||
self.detail = (detail or self.default_detail) % method
|
||||
|
||||
|
||||
class NotAcceptable(APIException):
|
||||
status_code = status.HTTP_406_NOT_ACCEPTABLE
|
||||
default_detail = "Could not satisfy the request's Accept header"
|
||||
|
||||
def __init__(self, detail=None, available_renderers=None):
|
||||
self.detail = detail or self.default_detail
|
||||
self.available_renderers = available_renderers
|
||||
|
||||
|
||||
class UnsupportedMediaType(APIException):
|
||||
status_code = status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
|
||||
default_detail = "Unsupported media type '%s' in request."
|
||||
|
|
74
djangorestframework/negotiation.py
Normal file
74
djangorestframework/negotiation.py
Normal file
|
@ -0,0 +1,74 @@
|
|||
from djangorestframework import exceptions
|
||||
from djangorestframework.settings import api_settings
|
||||
from djangorestframework.utils.mediatypes import order_by_precedence
|
||||
|
||||
|
||||
class BaseContentNegotiation(object):
|
||||
def negotiate(self, request, renderers, format=None, force=False):
|
||||
raise NotImplementedError('.negotiate() must be implemented')
|
||||
|
||||
|
||||
class DefaultContentNegotiation(object):
|
||||
settings = api_settings
|
||||
|
||||
def negotiate(self, request, renderers, format=None, force=False):
|
||||
"""
|
||||
Given a request and a list of renderers, return a two-tuple of:
|
||||
(renderer, media type).
|
||||
|
||||
If force is set, then suppress exceptions, and forcibly return a
|
||||
fallback renderer and media_type.
|
||||
"""
|
||||
try:
|
||||
return self.unforced_negotiate(request, renderers, format)
|
||||
except (exceptions.InvalidFormat, exceptions.NotAcceptable):
|
||||
if force:
|
||||
return (renderers[0], renderers[0].media_type)
|
||||
raise
|
||||
|
||||
def unforced_negotiate(self, request, renderers, format=None):
|
||||
"""
|
||||
As `.negotiate()`, but does not take the optional `force` agument,
|
||||
or suppress exceptions.
|
||||
"""
|
||||
# Allow URL style format override. eg. "?format=json
|
||||
format = format or request.GET.get(self.settings.URL_FORMAT_OVERRIDE)
|
||||
|
||||
if format:
|
||||
renderers = self.filter_renderers(renderers, format)
|
||||
|
||||
accepts = self.get_accept_list(request)
|
||||
|
||||
# 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 :)
|
||||
# Worst case is we're looping over len(accept_list) * len(self.renderers)
|
||||
for media_type_set in order_by_precedence(accepts):
|
||||
for renderer in renderers:
|
||||
for media_type in media_type_set:
|
||||
if renderer.can_handle_media_type(media_type):
|
||||
return renderer, media_type
|
||||
|
||||
raise exceptions.NotAcceptable(available_renderers=renderers)
|
||||
|
||||
def filter_renderers(self, renderers, format):
|
||||
"""
|
||||
If there is a '.json' style format suffix, filter the renderers
|
||||
so that we only negotiation against those that accept that format.
|
||||
"""
|
||||
renderers = [renderer for renderer in renderers
|
||||
if renderer.can_handle_format(format)]
|
||||
if not renderers:
|
||||
raise exceptions.InvalidFormat(format)
|
||||
return renderers
|
||||
|
||||
def get_accept_list(self, request):
|
||||
"""
|
||||
Given the incoming request, return a tokenised list of media
|
||||
type strings.
|
||||
|
||||
Allows URL style accept override. eg. "?accept=application/json"
|
||||
"""
|
||||
header = request.META.get('HTTP_ACCEPT', '*/*')
|
||||
header = request.GET.get(self.settings.URL_ACCEPT_OVERRIDE, header)
|
||||
return [token.strip() for token in header.split(',')]
|
|
@ -48,28 +48,22 @@ class BaseRenderer(object):
|
|||
def __init__(self, view=None):
|
||||
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.
|
||||
def can_handle_format(self, format):
|
||||
return format == self.format
|
||||
|
||||
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
|
||||
def can_handle_media_type(self, media_type):
|
||||
"""
|
||||
Returns `True` if this renderer is able to deal with the given
|
||||
media type.
|
||||
|
||||
The default implementation for this function is to check the media type
|
||||
argument against the 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.
|
||||
This may be overridden to provide for other behavior, but typically
|
||||
you'll instead want to just set the `media_type` attribute on the class.
|
||||
"""
|
||||
# TODO: format overriding must go out of here
|
||||
format = None
|
||||
if self.view is not None:
|
||||
format = self.view.kwargs.get(self._FORMAT_QUERY_PARAM, None)
|
||||
if format is None and self.view is not None:
|
||||
format = self.view.request.GET.get(self._FORMAT_QUERY_PARAM, None)
|
||||
|
||||
if format is not None:
|
||||
return format == self.format
|
||||
return media_type_matches(self.media_type, accept)
|
||||
return media_type_matches(self.media_type, media_type)
|
||||
|
||||
def render(self, obj=None, media_type=None):
|
||||
"""
|
||||
|
|
|
@ -1,97 +1,34 @@
|
|||
"""
|
||||
The :mod:`response` module provides :class:`Response` and :class:`ImmediateResponse` classes.
|
||||
|
||||
`Response` is a subclass of `HttpResponse`, and can be similarly instantiated and returned
|
||||
from any view. It is a bit smarter than Django's `HttpResponse`, for it renders automatically
|
||||
its content to a serial format by using a list of :mod:`renderers`.
|
||||
|
||||
To determine the content type to which it must render, default behaviour is to use standard
|
||||
HTTP Accept header content negotiation. But `Response` also supports overriding the content type
|
||||
by specifying an ``_accept=`` parameter in the URL. Also, `Response` will ignore `Accept` headers
|
||||
from Internet Explorer user agents and use a sensible browser `Accept` header instead.
|
||||
"""
|
||||
|
||||
|
||||
import re
|
||||
from django.template.response import SimpleTemplateResponse
|
||||
from django.core.handlers.wsgi import STATUS_CODE_TEXT
|
||||
from djangorestframework.settings import api_settings
|
||||
from djangorestframework.utils.mediatypes import order_by_precedence
|
||||
from djangorestframework import status
|
||||
|
||||
|
||||
MSIE_USER_AGENT_REGEX = re.compile(r'^Mozilla/[0-9]+\.[0-9]+ \([^)]*; MSIE [0-9]+\.[0-9]+[a-z]?;[^)]*\)(?!.* Opera )')
|
||||
|
||||
|
||||
class NotAcceptable(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Response(SimpleTemplateResponse):
|
||||
"""
|
||||
An HttpResponse that may include content that hasn't yet been serialized.
|
||||
|
||||
Kwargs:
|
||||
- content(object). The raw content, not yet serialized.
|
||||
This must be native Python data that renderers can handle.
|
||||
(e.g.: `dict`, `str`, ...)
|
||||
- renderer_classes(list/tuple). The renderers to use for rendering the response content.
|
||||
An HttpResponse that allows it's data to be rendered into
|
||||
arbitrary media types.
|
||||
"""
|
||||
|
||||
_ACCEPT_QUERY_PARAM = api_settings.URL_ACCEPT_OVERRIDE
|
||||
_IGNORE_IE_ACCEPT_HEADER = True
|
||||
def __init__(self, data=None, status=None, headers=None,
|
||||
renderer=None, media_type=None):
|
||||
"""
|
||||
Alters the init arguments slightly.
|
||||
For example, drop 'template_name', and instead use 'data'.
|
||||
|
||||
def __init__(self, content=None, status=None, headers=None, view=None,
|
||||
request=None, renderer_classes=None, format=None):
|
||||
# First argument taken by `SimpleTemplateResponse.__init__` is template_name,
|
||||
# which we don't need
|
||||
Setting 'renderer' and 'media_type' will typically be defered,
|
||||
For example being set automatically by the `APIView`.
|
||||
"""
|
||||
super(Response, self).__init__(None, status=status)
|
||||
|
||||
self.raw_content = content
|
||||
self.has_content_body = content is not None
|
||||
self.data = data
|
||||
self.headers = headers and headers[:] or []
|
||||
self.view = view
|
||||
self.request = request
|
||||
self.renderer_classes = renderer_classes
|
||||
self.format = format
|
||||
|
||||
def get_renderers(self):
|
||||
"""
|
||||
Instantiates and returns the list of renderers the response will use.
|
||||
"""
|
||||
if self.renderer_classes is None:
|
||||
renderer_classes = api_settings.DEFAULT_RENDERERS
|
||||
else:
|
||||
renderer_classes = self.renderer_classes
|
||||
|
||||
if self.format:
|
||||
return [cls(self.view) for cls in renderer_classes
|
||||
if cls.format == self.format]
|
||||
return [cls(self.view) for cls in renderer_classes]
|
||||
self.renderer = renderer
|
||||
self.media_type = media_type
|
||||
|
||||
@property
|
||||
def rendered_content(self):
|
||||
"""
|
||||
The final rendered content. Accessing this attribute triggers the
|
||||
complete rendering cycle: selecting suitable renderer, setting
|
||||
response's actual content type, rendering data.
|
||||
"""
|
||||
renderer, media_type = self._determine_renderer()
|
||||
|
||||
# Set the media type of the response
|
||||
self['Content-Type'] = renderer.media_type
|
||||
|
||||
# Render the response content
|
||||
if self.has_content_body:
|
||||
return renderer.render(self.raw_content, media_type)
|
||||
return renderer.render()
|
||||
|
||||
def render(self):
|
||||
try:
|
||||
return super(Response, self).render()
|
||||
except NotAcceptable:
|
||||
response = self._get_406_response()
|
||||
return response.render()
|
||||
self['Content-Type'] = self.renderer.media_type
|
||||
if self.data is None:
|
||||
return self.renderer.render()
|
||||
return self.renderer.render(self.data, self.media_type)
|
||||
|
||||
@property
|
||||
def status_text(self):
|
||||
|
@ -100,74 +37,3 @@ class Response(SimpleTemplateResponse):
|
|||
Provided for convenience.
|
||||
"""
|
||||
return STATUS_CODE_TEXT.get(self.status_code, '')
|
||||
|
||||
def _determine_accept_list(self):
|
||||
"""
|
||||
Returns a list of accepted media types. This list is determined from :
|
||||
|
||||
1. overload with `_ACCEPT_QUERY_PARAM`
|
||||
2. `Accept` header of the request
|
||||
|
||||
If those are useless, a default value is returned instead.
|
||||
"""
|
||||
request = self.request
|
||||
|
||||
if (self._ACCEPT_QUERY_PARAM and
|
||||
request.GET.get(self._ACCEPT_QUERY_PARAM, None)):
|
||||
# Use _accept parameter override
|
||||
return [request.GET.get(self._ACCEPT_QUERY_PARAM)]
|
||||
elif (self._IGNORE_IE_ACCEPT_HEADER and
|
||||
'HTTP_USER_AGENT' in request.META and
|
||||
MSIE_USER_AGENT_REGEX.match(request.META['HTTP_USER_AGENT']) and
|
||||
request.META.get('HTTP_X_REQUESTED_WITH', '') != 'XMLHttpRequest'):
|
||||
# Ignore MSIE's broken accept behavior except for AJAX requests
|
||||
# and do something sensible instead
|
||||
return ['text/html', '*/*']
|
||||
elif 'HTTP_ACCEPT' in request.META:
|
||||
# Use standard HTTP Accept negotiation
|
||||
return [token.strip() for token in request.META['HTTP_ACCEPT'].split(',')]
|
||||
else:
|
||||
# No accept header specified
|
||||
return ['*/*']
|
||||
|
||||
def _determine_renderer(self):
|
||||
"""
|
||||
Determines the appropriate renderer for the output, given the list of
|
||||
accepted media types, and the :attr:`renderer_classes` set on this class.
|
||||
|
||||
Returns a 2-tuple of `(renderer, media_type)`
|
||||
|
||||
See: RFC 2616, Section 14
|
||||
http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html
|
||||
"""
|
||||
|
||||
renderers = self.get_renderers()
|
||||
accepts = self._determine_accept_list()
|
||||
|
||||
# Not acceptable response - Ignore accept header.
|
||||
if self.status_code == 406:
|
||||
return (renderers[0], renderers[0].media_type)
|
||||
|
||||
# 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 :)
|
||||
# Worst case is we're looping over len(accept_list) * len(self.renderers)
|
||||
for media_type_set in order_by_precedence(accepts):
|
||||
for renderer in renderers:
|
||||
for media_type in media_type_set:
|
||||
if renderer.can_handle_response(media_type):
|
||||
return renderer, media_type
|
||||
|
||||
# No acceptable renderers were found
|
||||
raise NotAcceptable
|
||||
|
||||
def _get_406_response(self):
|
||||
renderer = self.renderer_classes[0]
|
||||
return Response(
|
||||
{
|
||||
'detail': 'Could not satisfy the client\'s Accept header',
|
||||
'available_types': [renderer.media_type
|
||||
for renderer in self.renderer_classes]
|
||||
},
|
||||
status=status.HTTP_406_NOT_ACCEPTABLE,
|
||||
view=self.view, request=self.request, renderer_classes=[renderer])
|
||||
|
|
|
@ -38,6 +38,7 @@ DEFAULTS = {
|
|||
),
|
||||
'DEFAULT_PERMISSIONS': (),
|
||||
'DEFAULT_THROTTLES': (),
|
||||
'DEFAULT_CONTENT_NEGOTIATION': 'djangorestframework.negotiation.DefaultContentNegotiation',
|
||||
|
||||
'UNAUTHENTICATED_USER': 'django.contrib.auth.models.AnonymousUser',
|
||||
'UNAUTHENTICATED_TOKEN': None,
|
||||
|
@ -46,6 +47,7 @@ DEFAULTS = {
|
|||
'FORM_CONTENT_OVERRIDE': '_content',
|
||||
'FORM_CONTENTTYPE_OVERRIDE': '_content_type',
|
||||
'URL_ACCEPT_OVERRIDE': '_accept',
|
||||
'URL_FORMAT_OVERRIDE': 'format',
|
||||
|
||||
'FORMAT_SUFFIX_KWARG': 'format'
|
||||
}
|
||||
|
@ -58,8 +60,9 @@ IMPORT_STRINGS = (
|
|||
'DEFAULT_AUTHENTICATION',
|
||||
'DEFAULT_PERMISSIONS',
|
||||
'DEFAULT_THROTTLES',
|
||||
'DEFAULT_CONTENT_NEGOTIATION',
|
||||
'UNAUTHENTICATED_USER',
|
||||
'UNAUTHENTICATED_TOKEN'
|
||||
'UNAUTHENTICATED_TOKEN',
|
||||
)
|
||||
|
||||
|
||||
|
@ -68,7 +71,7 @@ def perform_import(val, setting):
|
|||
If the given setting is a string import notation,
|
||||
then perform the necessary import or imports.
|
||||
"""
|
||||
if val is None or setting not in IMPORT_STRINGS:
|
||||
if val is None or not setting in IMPORT_STRINGS:
|
||||
return val
|
||||
|
||||
if isinstance(val, basestring):
|
||||
|
@ -88,10 +91,7 @@ def import_from_string(val, setting):
|
|||
module_path, class_name = '.'.join(parts[:-1]), parts[-1]
|
||||
module = importlib.import_module(module_path)
|
||||
return getattr(module, class_name)
|
||||
except Exception, e:
|
||||
import traceback
|
||||
tb = traceback.format_exc()
|
||||
import pdb; pdb.set_trace()
|
||||
except:
|
||||
msg = "Could not import '%s' for API setting '%s'" % (val, setting)
|
||||
raise ImportError(msg)
|
||||
|
||||
|
|
|
@ -1,83 +0,0 @@
|
|||
from django.conf.urls.defaults import patterns, url, include
|
||||
from django.test import TestCase
|
||||
|
||||
from djangorestframework.compat import RequestFactory
|
||||
from djangorestframework.views import APIView
|
||||
from djangorestframework.response import Response
|
||||
|
||||
|
||||
# See: http://www.useragentstring.com/
|
||||
MSIE_9_USER_AGENT = 'Mozilla/5.0 (Windows; U; MSIE 9.0; WIndows NT 9.0; en-US))'
|
||||
MSIE_8_USER_AGENT = 'Mozilla/5.0 (compatible; MSIE 8.0; Windows NT 5.2; Trident/4.0; Media Center PC 4.0; SLCC1; .NET CLR 3.0.04320)'
|
||||
MSIE_7_USER_AGENT = 'Mozilla/5.0 (Windows; U; MSIE 7.0; Windows NT 6.0; en-US)'
|
||||
FIREFOX_4_0_USER_AGENT = 'Mozilla/5.0 (Windows; U; Windows NT 6.1; ru; rv:1.9.2.3) Gecko/20100401 Firefox/4.0 (.NET CLR 3.5.30729)'
|
||||
CHROME_11_0_USER_AGENT = 'Mozilla/5.0 (Windows; U; Windows NT 6.1; en-US) AppleWebKit/534.17 (KHTML, like Gecko) Chrome/11.0.655.0 Safari/534.17'
|
||||
SAFARI_5_0_USER_AGENT = 'Mozilla/5.0 (X11; U; Linux x86_64; en-ca) AppleWebKit/531.2+ (KHTML, like Gecko) Version/5.0 Safari/531.2+'
|
||||
OPERA_11_0_MSIE_USER_AGENT = 'Mozilla/4.0 (compatible; MSIE 8.0; X11; Linux x86_64; pl) Opera 11.00'
|
||||
OPERA_11_0_OPERA_USER_AGENT = 'Opera/9.80 (X11; Linux x86_64; U; pl) Presto/2.7.62 Version/11.00'
|
||||
|
||||
|
||||
urlpatterns = patterns('',
|
||||
url(r'^api', include('djangorestframework.urls', namespace='djangorestframework'))
|
||||
)
|
||||
|
||||
|
||||
class UserAgentMungingTest(TestCase):
|
||||
"""
|
||||
We need to fake up the accept headers when we deal with MSIE. Blergh.
|
||||
http://www.gethifi.com/blog/browser-rest-http-accept-headers
|
||||
"""
|
||||
|
||||
urls = 'djangorestframework.tests.accept'
|
||||
|
||||
def setUp(self):
|
||||
|
||||
class MockView(APIView):
|
||||
permissions = ()
|
||||
response_class = Response
|
||||
|
||||
def get(self, request):
|
||||
return self.response_class({'a': 1, 'b': 2, 'c': 3})
|
||||
|
||||
self.req = RequestFactory()
|
||||
self.MockView = MockView
|
||||
self.view = MockView.as_view()
|
||||
|
||||
def test_munge_msie_accept_header(self):
|
||||
"""Send MSIE user agent strings and ensure that we get an HTML response,
|
||||
even if we set a */* accept header."""
|
||||
for user_agent in (MSIE_9_USER_AGENT,
|
||||
MSIE_8_USER_AGENT,
|
||||
MSIE_7_USER_AGENT):
|
||||
req = self.req.get('/', HTTP_ACCEPT='*/*', HTTP_USER_AGENT=user_agent)
|
||||
resp = self.view(req)
|
||||
resp.render()
|
||||
self.assertEqual(resp['Content-Type'], 'text/html')
|
||||
|
||||
def test_dont_rewrite_msie_accept_header(self):
|
||||
"""Turn off _IGNORE_IE_ACCEPT_HEADER, send MSIE user agent strings and ensure
|
||||
that we get a JSON response if we set a */* accept header."""
|
||||
class IgnoreIEAcceptResponse(Response):
|
||||
_IGNORE_IE_ACCEPT_HEADER = False
|
||||
view = self.MockView.as_view(response_class=IgnoreIEAcceptResponse)
|
||||
|
||||
for user_agent in (MSIE_9_USER_AGENT,
|
||||
MSIE_8_USER_AGENT,
|
||||
MSIE_7_USER_AGENT):
|
||||
req = self.req.get('/', HTTP_ACCEPT='*/*', HTTP_USER_AGENT=user_agent)
|
||||
resp = view(req)
|
||||
resp.render()
|
||||
self.assertEqual(resp['Content-Type'], 'application/json')
|
||||
|
||||
def test_dont_munge_nice_browsers_accept_header(self):
|
||||
"""Send Non-MSIE user agent strings and ensure that we get a JSON response,
|
||||
if we set a */* Accept header. (Other browsers will correctly set the Accept header)"""
|
||||
for user_agent in (FIREFOX_4_0_USER_AGENT,
|
||||
CHROME_11_0_USER_AGENT,
|
||||
SAFARI_5_0_USER_AGENT,
|
||||
OPERA_11_0_MSIE_USER_AGENT,
|
||||
OPERA_11_0_OPERA_USER_AGENT):
|
||||
req = self.req.get('/', HTTP_ACCEPT='*/*', HTTP_USER_AGENT=user_agent)
|
||||
resp = self.view(req)
|
||||
resp.render()
|
||||
self.assertEqual(resp['Content-Type'], 'application/json')
|
|
@ -169,15 +169,6 @@ class RendererEndToEndTests(TestCase):
|
|||
self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT))
|
||||
self.assertEquals(resp.status_code, DUMMYSTATUS)
|
||||
|
||||
def test_conflicting_format_query_and_accept_ignores_accept(self):
|
||||
"""If a 'format' query is specified that does not match the Accept
|
||||
header, we should only honor the 'format' query string."""
|
||||
resp = self.client.get('/?format=%s' % RendererB.format,
|
||||
HTTP_ACCEPT='dummy')
|
||||
self.assertEquals(resp['Content-Type'], RendererB.media_type)
|
||||
self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT))
|
||||
self.assertEquals(resp.status_code, DUMMYSTATUS)
|
||||
|
||||
|
||||
_flat_repr = '{"foo": ["bar", "baz"]}'
|
||||
_indented_repr = '{\n "foo": [\n "bar",\n "baz"\n ]\n}'
|
||||
|
|
|
@ -7,7 +7,7 @@ from django.test import TestCase, Client
|
|||
|
||||
from djangorestframework import status
|
||||
from djangorestframework.authentication import SessionAuthentication
|
||||
from djangorestframework.utils import RequestFactory
|
||||
from djangorestframework.compat import RequestFactory
|
||||
from djangorestframework.parsers import (
|
||||
FormParser,
|
||||
MultiPartParser,
|
||||
|
@ -22,33 +22,21 @@ factory = RequestFactory()
|
|||
|
||||
|
||||
class TestMethodOverloading(TestCase):
|
||||
def test_GET_method(self):
|
||||
def test_method(self):
|
||||
"""
|
||||
GET requests identified.
|
||||
Request methods should be same as underlying request.
|
||||
"""
|
||||
request = factory.get('/')
|
||||
request = Request(factory.get('/'))
|
||||
self.assertEqual(request.method, 'GET')
|
||||
|
||||
def test_POST_method(self):
|
||||
"""
|
||||
POST requests identified.
|
||||
"""
|
||||
request = factory.post('/')
|
||||
request = Request(factory.post('/'))
|
||||
self.assertEqual(request.method, 'POST')
|
||||
|
||||
def test_HEAD_method(self):
|
||||
"""
|
||||
HEAD requests identified.
|
||||
"""
|
||||
request = factory.head('/')
|
||||
self.assertEqual(request.method, 'HEAD')
|
||||
|
||||
def test_overloaded_method(self):
|
||||
"""
|
||||
POST requests can be overloaded to another method by setting a
|
||||
reserved form field
|
||||
"""
|
||||
request = factory.post('/', {Request._METHOD_PARAM: 'DELETE'})
|
||||
request = Request(factory.post('/', {Request._METHOD_PARAM: 'DELETE'}))
|
||||
self.assertEqual(request.method, 'DELETE')
|
||||
|
||||
|
||||
|
@ -57,14 +45,14 @@ class TestContentParsing(TestCase):
|
|||
"""
|
||||
Ensure request.DATA returns None for GET request with no content.
|
||||
"""
|
||||
request = factory.get('/')
|
||||
request = Request(factory.get('/'))
|
||||
self.assertEqual(request.DATA, None)
|
||||
|
||||
def test_standard_behaviour_determines_no_content_HEAD(self):
|
||||
"""
|
||||
Ensure request.DATA returns None for HEAD request.
|
||||
"""
|
||||
request = factory.head('/')
|
||||
request = Request(factory.head('/'))
|
||||
self.assertEqual(request.DATA, None)
|
||||
|
||||
def test_standard_behaviour_determines_form_content_POST(self):
|
||||
|
@ -72,8 +60,8 @@ class TestContentParsing(TestCase):
|
|||
Ensure request.DATA returns content for POST request with form content.
|
||||
"""
|
||||
data = {'qwerty': 'uiop'}
|
||||
parsers = (FormParser, MultiPartParser)
|
||||
request = factory.post('/', data, parser=parsers)
|
||||
request = Request(factory.post('/', data))
|
||||
request.parser_classes = (FormParser, MultiPartParser)
|
||||
self.assertEqual(request.DATA.items(), data.items())
|
||||
|
||||
def test_standard_behaviour_determines_non_form_content_POST(self):
|
||||
|
@ -83,9 +71,8 @@ class TestContentParsing(TestCase):
|
|||
"""
|
||||
content = 'qwerty'
|
||||
content_type = 'text/plain'
|
||||
parsers = (PlainTextParser,)
|
||||
request = factory.post('/', content, content_type=content_type,
|
||||
parsers=parsers)
|
||||
request = Request(factory.post('/', content, content_type=content_type))
|
||||
request.parser_classes = (PlainTextParser,)
|
||||
self.assertEqual(request.DATA, content)
|
||||
|
||||
def test_standard_behaviour_determines_form_content_PUT(self):
|
||||
|
@ -93,17 +80,17 @@ class TestContentParsing(TestCase):
|
|||
Ensure request.DATA returns content for PUT request with form content.
|
||||
"""
|
||||
data = {'qwerty': 'uiop'}
|
||||
parsers = (FormParser, MultiPartParser)
|
||||
|
||||
from django import VERSION
|
||||
|
||||
if VERSION >= (1, 5):
|
||||
from django.test.client import MULTIPART_CONTENT, BOUNDARY, encode_multipart
|
||||
request = factory.put('/', encode_multipart(BOUNDARY, data), parsers=parsers,
|
||||
content_type=MULTIPART_CONTENT)
|
||||
request = Request(factory.put('/', encode_multipart(BOUNDARY, data),
|
||||
content_type=MULTIPART_CONTENT))
|
||||
else:
|
||||
request = factory.put('/', data, parsers=parsers)
|
||||
request = Request(factory.put('/', data))
|
||||
|
||||
request.parser_classes = (FormParser, MultiPartParser)
|
||||
self.assertEqual(request.DATA.items(), data.items())
|
||||
|
||||
def test_standard_behaviour_determines_non_form_content_PUT(self):
|
||||
|
@ -113,9 +100,8 @@ class TestContentParsing(TestCase):
|
|||
"""
|
||||
content = 'qwerty'
|
||||
content_type = 'text/plain'
|
||||
parsers = (PlainTextParser, )
|
||||
request = factory.put('/', content, content_type=content_type,
|
||||
parsers=parsers)
|
||||
request = Request(factory.put('/', content, content_type=content_type))
|
||||
request.parser_classes = (PlainTextParser, )
|
||||
self.assertEqual(request.DATA, content)
|
||||
|
||||
def test_overloaded_behaviour_allows_content_tunnelling(self):
|
||||
|
@ -128,8 +114,8 @@ class TestContentParsing(TestCase):
|
|||
Request._CONTENT_PARAM: content,
|
||||
Request._CONTENTTYPE_PARAM: content_type
|
||||
}
|
||||
parsers = (PlainTextParser, )
|
||||
request = factory.post('/', data, parsers=parsers)
|
||||
request = Request(factory.post('/', data))
|
||||
request.parser_classes = (PlainTextParser, )
|
||||
self.assertEqual(request.DATA, content)
|
||||
|
||||
# def test_accessing_post_after_data_form(self):
|
||||
|
|
|
@ -1,18 +1,15 @@
|
|||
import json
|
||||
import unittest
|
||||
|
||||
from django.conf.urls.defaults import patterns, url, include
|
||||
from django.test import TestCase
|
||||
|
||||
from djangorestframework.response import Response, NotAcceptable
|
||||
from djangorestframework.response import Response
|
||||
from djangorestframework.views import APIView
|
||||
from djangorestframework.compat import RequestFactory
|
||||
from djangorestframework import status
|
||||
from djangorestframework.renderers import (
|
||||
BaseRenderer,
|
||||
JSONRenderer,
|
||||
DocumentingHTMLRenderer,
|
||||
DEFAULT_RENDERERS
|
||||
DocumentingHTMLRenderer
|
||||
)
|
||||
|
||||
|
||||
|
@ -24,126 +21,6 @@ class MockJsonRenderer(BaseRenderer):
|
|||
media_type = 'application/json'
|
||||
|
||||
|
||||
class TestResponseDetermineRenderer(TestCase):
|
||||
|
||||
def get_response(self, url='', accept_list=[], renderer_classes=[]):
|
||||
kwargs = {}
|
||||
if accept_list is not None:
|
||||
kwargs['HTTP_ACCEPT'] = ','.join(accept_list)
|
||||
request = RequestFactory().get(url, **kwargs)
|
||||
return Response(request=request, renderer_classes=renderer_classes)
|
||||
|
||||
def test_determine_accept_list_accept_header(self):
|
||||
"""
|
||||
Test that determine_accept_list takes the Accept header.
|
||||
"""
|
||||
accept_list = ['application/pickle', 'application/json']
|
||||
response = self.get_response(accept_list=accept_list)
|
||||
self.assertEqual(response._determine_accept_list(), accept_list)
|
||||
|
||||
def test_determine_accept_list_default(self):
|
||||
"""
|
||||
Test that determine_accept_list takes the default renderer if Accept is not specified.
|
||||
"""
|
||||
response = self.get_response(accept_list=None)
|
||||
self.assertEqual(response._determine_accept_list(), ['*/*'])
|
||||
|
||||
def test_determine_accept_list_overriden_header(self):
|
||||
"""
|
||||
Test Accept header overriding.
|
||||
"""
|
||||
accept_list = ['application/pickle', 'application/json']
|
||||
response = self.get_response(url='?_accept=application/x-www-form-urlencoded',
|
||||
accept_list=accept_list)
|
||||
self.assertEqual(response._determine_accept_list(), ['application/x-www-form-urlencoded'])
|
||||
|
||||
def test_determine_renderer(self):
|
||||
"""
|
||||
Test that right renderer is chosen, in the order of Accept list.
|
||||
"""
|
||||
accept_list = ['application/pickle', 'application/json']
|
||||
renderer_classes = (MockPickleRenderer, MockJsonRenderer)
|
||||
response = self.get_response(accept_list=accept_list, renderer_classes=renderer_classes)
|
||||
renderer, media_type = response._determine_renderer()
|
||||
self.assertEqual(media_type, 'application/pickle')
|
||||
self.assertTrue(isinstance(renderer, MockPickleRenderer))
|
||||
|
||||
renderer_classes = (MockJsonRenderer, )
|
||||
response = self.get_response(accept_list=accept_list, renderer_classes=renderer_classes)
|
||||
renderer, media_type = response._determine_renderer()
|
||||
self.assertEqual(media_type, 'application/json')
|
||||
self.assertTrue(isinstance(renderer, MockJsonRenderer))
|
||||
|
||||
def test_determine_renderer_default(self):
|
||||
"""
|
||||
Test determine renderer when Accept was not specified.
|
||||
"""
|
||||
renderer_classes = (MockPickleRenderer, )
|
||||
response = self.get_response(accept_list=None, renderer_classes=renderer_classes)
|
||||
renderer, media_type = response._determine_renderer()
|
||||
self.assertEqual(media_type, '*/*')
|
||||
self.assertTrue(isinstance(renderer, MockPickleRenderer))
|
||||
|
||||
def test_determine_renderer_no_renderer(self):
|
||||
"""
|
||||
Test determine renderer when no renderer can satisfy the Accept list.
|
||||
"""
|
||||
accept_list = ['application/json']
|
||||
renderer_classes = (MockPickleRenderer, )
|
||||
response = self.get_response(accept_list=accept_list, renderer_classes=renderer_classes)
|
||||
self.assertRaises(NotAcceptable, response._determine_renderer)
|
||||
|
||||
|
||||
class TestResponseRenderContent(TestCase):
|
||||
def get_response(self, url='', accept_list=[], content=None, renderer_classes=None):
|
||||
request = RequestFactory().get(url, HTTP_ACCEPT=','.join(accept_list))
|
||||
return Response(request=request, content=content, renderer_classes=renderer_classes or DEFAULT_RENDERERS)
|
||||
|
||||
def test_render(self):
|
||||
"""
|
||||
Test rendering simple data to json.
|
||||
"""
|
||||
content = {'a': 1, 'b': [1, 2, 3]}
|
||||
content_type = 'application/json'
|
||||
response = self.get_response(accept_list=[content_type], content=content)
|
||||
response = response.render()
|
||||
self.assertEqual(json.loads(response.content), content)
|
||||
self.assertEqual(response['Content-Type'], content_type)
|
||||
|
||||
def test_render_no_renderer(self):
|
||||
"""
|
||||
Test rendering response when no renderer can satisfy accept.
|
||||
"""
|
||||
content = 'bla'
|
||||
content_type = 'weirdcontenttype'
|
||||
response = self.get_response(accept_list=[content_type], content=content)
|
||||
response = response.render()
|
||||
self.assertEqual(response.status_code, 406)
|
||||
self.assertIsNotNone(response.content)
|
||||
|
||||
# def test_render_renderer_raises_ImmediateResponse(self):
|
||||
# """
|
||||
# Test rendering response when renderer raises ImmediateResponse
|
||||
# """
|
||||
# class PickyJSONRenderer(BaseRenderer):
|
||||
# """
|
||||
# A renderer that doesn't make much sense, just to try
|
||||
# out raising an ImmediateResponse
|
||||
# """
|
||||
# media_type = 'application/json'
|
||||
|
||||
# def render(self, obj=None, media_type=None):
|
||||
# raise ImmediateResponse({'error': '!!!'}, status=400)
|
||||
|
||||
# response = self.get_response(
|
||||
# accept_list=['application/json'],
|
||||
# renderers=[PickyJSONRenderer, JSONRenderer]
|
||||
# )
|
||||
# response = response.render()
|
||||
# self.assertEqual(response.status_code, 400)
|
||||
# self.assertEqual(response.content, json.dumps({'error': '!!!'}))
|
||||
|
||||
|
||||
DUMMYSTATUS = status.HTTP_200_OK
|
||||
DUMMYCONTENT = 'dummycontent'
|
||||
|
||||
|
@ -280,15 +157,6 @@ class RendererIntegrationTests(TestCase):
|
|||
self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT))
|
||||
self.assertEquals(resp.status_code, DUMMYSTATUS)
|
||||
|
||||
def test_conflicting_format_query_and_accept_ignores_accept(self):
|
||||
"""If a 'format' query is specified that does not match the Accept
|
||||
header, we should only honor the 'format' query string."""
|
||||
resp = self.client.get('/?format=%s' % RendererB.format,
|
||||
HTTP_ACCEPT='dummy')
|
||||
self.assertEquals(resp['Content-Type'], RendererB.media_type)
|
||||
self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT))
|
||||
self.assertEquals(resp.status_code, DUMMYSTATUS)
|
||||
|
||||
|
||||
class Issue122Tests(TestCase):
|
||||
"""
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
from django.utils.encoding import smart_unicode
|
||||
from django.utils.xmlutils import SimplerXMLGenerator
|
||||
|
||||
from djangorestframework.compat import StringIO
|
||||
from djangorestframework.compat import RequestFactory as DjangoRequestFactory
|
||||
from djangorestframework.request import Request
|
||||
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
|
@ -102,38 +99,3 @@ class XMLRenderer():
|
|||
|
||||
def dict2xml(input):
|
||||
return XMLRenderer().dict2xml(input)
|
||||
|
||||
|
||||
class RequestFactory(DjangoRequestFactory):
|
||||
"""
|
||||
Replicate RequestFactory, but return Request, not HttpRequest.
|
||||
"""
|
||||
def get(self, *args, **kwargs):
|
||||
parsers = kwargs.pop('parsers', None)
|
||||
request = super(RequestFactory, self).get(*args, **kwargs)
|
||||
return Request(request, parsers)
|
||||
|
||||
def post(self, *args, **kwargs):
|
||||
parsers = kwargs.pop('parsers', None)
|
||||
request = super(RequestFactory, self).post(*args, **kwargs)
|
||||
return Request(request, parsers)
|
||||
|
||||
def put(self, *args, **kwargs):
|
||||
parsers = kwargs.pop('parsers', None)
|
||||
request = super(RequestFactory, self).put(*args, **kwargs)
|
||||
return Request(request, parsers)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
parsers = kwargs.pop('parsers', None)
|
||||
request = super(RequestFactory, self).delete(*args, **kwargs)
|
||||
return Request(request, parsers)
|
||||
|
||||
def head(self, *args, **kwargs):
|
||||
parsers = kwargs.pop('parsers', None)
|
||||
request = super(RequestFactory, self).head(*args, **kwargs)
|
||||
return Request(request, parsers)
|
||||
|
||||
def options(self, *args, **kwargs):
|
||||
parsers = kwargs.pop('parsers', None)
|
||||
request = super(RequestFactory, self).options(*args, **kwargs)
|
||||
return Request(request, parsers)
|
||||
|
|
|
@ -54,11 +54,14 @@ def _camelcase_to_spaces(content):
|
|||
|
||||
|
||||
class APIView(_View):
|
||||
settings = api_settings
|
||||
|
||||
renderer_classes = api_settings.DEFAULT_RENDERERS
|
||||
parser_classes = api_settings.DEFAULT_PARSERS
|
||||
authentication_classes = api_settings.DEFAULT_AUTHENTICATION
|
||||
throttle_classes = api_settings.DEFAULT_THROTTLES
|
||||
permission_classes = api_settings.DEFAULT_PERMISSIONS
|
||||
content_negotiation_class = api_settings.DEFAULT_CONTENT_NEGOTIATION
|
||||
|
||||
@classmethod
|
||||
def as_view(cls, **initkwargs):
|
||||
|
@ -169,6 +172,19 @@ class APIView(_View):
|
|||
"""
|
||||
return self.renderer_classes[0]
|
||||
|
||||
def get_format_suffix(self, **kwargs):
|
||||
"""
|
||||
Determine if the request includes a '.json' style format suffix
|
||||
"""
|
||||
if self.settings.FORMAT_SUFFIX_KWARG:
|
||||
return kwargs.get(self.settings.FORMAT_SUFFIX_KWARG)
|
||||
|
||||
def get_renderers(self, format=None):
|
||||
"""
|
||||
Instantiates and returns the list of renderers that this view can use.
|
||||
"""
|
||||
return [renderer(self) for renderer in self.renderer_classes]
|
||||
|
||||
def get_permissions(self):
|
||||
"""
|
||||
Instantiates and returns the list of permissions that this view requires.
|
||||
|
@ -177,10 +193,18 @@ class APIView(_View):
|
|||
|
||||
def get_throttles(self):
|
||||
"""
|
||||
Instantiates and returns the list of thottles that this view requires.
|
||||
Instantiates and returns the list of thottles that this view uses.
|
||||
"""
|
||||
return [throttle(self) for throttle in self.throttle_classes]
|
||||
|
||||
def content_negotiation(self, request, force=False):
|
||||
"""
|
||||
Determine which renderer and media type to use render the response.
|
||||
"""
|
||||
renderers = self.get_renderers()
|
||||
conneg = self.content_negotiation_class()
|
||||
return conneg.negotiate(request, renderers, self.format, force)
|
||||
|
||||
def check_permissions(self, request, obj=None):
|
||||
"""
|
||||
Check if request should be permitted.
|
||||
|
@ -204,35 +228,37 @@ class APIView(_View):
|
|||
return Request(request, parser_classes=self.parser_classes,
|
||||
authentication_classes=self.authentication_classes)
|
||||
|
||||
def initial(self, request, *args, **kwargs):
|
||||
"""
|
||||
Runs anything that needs to occur prior to calling the method handlers.
|
||||
"""
|
||||
self.format = self.get_format_suffix(**kwargs)
|
||||
self.check_permissions(request)
|
||||
self.check_throttles(request)
|
||||
self.renderer, self.media_type = self.content_negotiation(request)
|
||||
|
||||
def finalize_response(self, request, response, *args, **kwargs):
|
||||
"""
|
||||
Returns the final response object.
|
||||
"""
|
||||
if isinstance(response, Response):
|
||||
response.view = self
|
||||
response.request = request
|
||||
response.renderer_classes = self.renderer_classes
|
||||
if api_settings.FORMAT_SUFFIX_KWARG:
|
||||
response.format = kwargs.get(api_settings.FORMAT_SUFFIX_KWARG, None)
|
||||
if not getattr(self, 'renderer', None):
|
||||
self.renderer, self.media_type = self.content_negotiation(request, force=True)
|
||||
response.renderer = self.renderer
|
||||
response.media_type = self.media_type
|
||||
|
||||
for key, value in self.headers.items():
|
||||
response[key] = value
|
||||
|
||||
return response
|
||||
|
||||
def initial(self, request, *args, **kwargs):
|
||||
"""
|
||||
Runs anything that needs to occur prior to calling the method handlers.
|
||||
"""
|
||||
self.check_permissions(request)
|
||||
self.check_throttles(request)
|
||||
|
||||
def handle_exception(self, exc):
|
||||
"""
|
||||
Handle any exception that occurs, by returning an appropriate response,
|
||||
or re-raising the error.
|
||||
"""
|
||||
if isinstance(exc, exceptions.Throttled):
|
||||
# Throttle wait header
|
||||
self.headers['X-Throttle-Wait-Seconds'] = '%d' % exc.wait
|
||||
|
||||
if isinstance(exc, exceptions.APIException):
|
||||
|
@ -250,14 +276,8 @@ class APIView(_View):
|
|||
@csrf_exempt
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
"""
|
||||
`APIView.dispatch()` is pretty much the same as Django's regular
|
||||
`View.dispatch()`, except that it includes hooks to:
|
||||
|
||||
* Initialize the request object.
|
||||
* Finalize the response object.
|
||||
* Handle exceptions that occur in the handler method.
|
||||
* An initial hook for code such as permission checking that should
|
||||
occur prior to running the method handlers.
|
||||
`.dispatch()` is pretty much the same as Django's regular dispatch,
|
||||
but with extra hooks for startup, finalize, and exception handling.
|
||||
"""
|
||||
request = self.initialize_request(request, *args, **kwargs)
|
||||
self.request = request
|
||||
|
@ -270,7 +290,8 @@ class APIView(_View):
|
|||
|
||||
# Get the appropriate handler method
|
||||
if request.method.lower() in self.http_method_names:
|
||||
handler = getattr(self, request.method.lower(), self.http_method_not_allowed)
|
||||
handler = getattr(self, request.method.lower(),
|
||||
self.http_method_not_allowed)
|
||||
else:
|
||||
handler = self.http_method_not_allowed
|
||||
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
<a class="github" href="negotiation.py"></a>
|
||||
|
||||
# Content negotiation
|
||||
|
||||
> HTTP has provisions for several mechanisms for "content negotiation" - the process of selecting the best representation for a given response when there are multiple representations available.
|
||||
|
|
52
docs/topics/rest-hypermedia-hateoas.md
Normal file
52
docs/topics/rest-hypermedia-hateoas.md
Normal file
|
@ -0,0 +1,52 @@
|
|||
> You keep using that word "REST". I do not think it means what you think it means.
|
||||
>
|
||||
> — Mike Amundsen, [talking at REST fest 2012][cite].
|
||||
|
||||
# REST, Hypermedia & HATEOAS
|
||||
|
||||
First off, the disclaimer. The name "Django REST framework" was choosen with a view to making sure the project would be easily found by developers. Throughout the documentation we try to use the more simple and technically correct terminology of "Web APIs".
|
||||
|
||||
If you are serious about designing a Hypermedia APIs, you should look to resources outside of this documentation to help inform your design choices.
|
||||
|
||||
The following fall into the "required reading" category.
|
||||
|
||||
* Fielding's dissertation - [Architectural Styles and
|
||||
the Design of Network-based Software Architectures][dissertation].
|
||||
* Fielding's "[REST APIs must be hypertext-driven][hypertext-driven]" blog post.
|
||||
* Leonard Richardson & Sam Ruby's [RESTful Web Services][restful-web-services].
|
||||
* Mike Amundsen's [Building Hypermedia APIs with HTML5 and Node][building-hypermedia-apis].
|
||||
* Steve Klabnik's [Designing Hypermedia APIs][designing-hypermedia-apis].
|
||||
* The [Richardson Maturity Model][maturitymodel].
|
||||
|
||||
For a more thorough background, check out Klabnik's [Hypermedia API reading list][readinglist].
|
||||
|
||||
# Building Hypermedia APIs with REST framework
|
||||
|
||||
REST framework is an agnositic Web API toolkit. It does help guide you towards building well-connected APIs, and makes it easy to design appropriate media types, but it does not strictly enforce any particular design style.
|
||||
|
||||
### What REST framework *does* provide.
|
||||
|
||||
It is self evident that REST framework makes it possible to build Hypermedia APIs. The browseable API that it offers is built on HTML - the hypermedia language of the web.
|
||||
|
||||
REST framework also includes [serialization] and [parser]/[renderer] components that make it easy to build appropriate media types, [hyperlinked relations][fields] for building well-connected systems, and great support for [content negotiation][conneg].
|
||||
|
||||
### What REST framework *doesn't* provide.
|
||||
|
||||
What REST framework doesn't do is give you is machine readable hypermedia formats such as [Collection+JSON][collection] by default, or the ability to auto-magically create HATEOAS style APIs. Doing so would involve making opinionated choices about API design that should really remain outside of the framework's scope.
|
||||
|
||||
[cite]: http://vimeo.com/channels/restfest/page:2
|
||||
[dissertation]: http://www.ics.uci.edu/~fielding/pubs/dissertation/top.htm
|
||||
[hypertext-driven]: http://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven
|
||||
[restful-web-services]:
|
||||
[building-hypermedia-apis]: …
|
||||
[designing-hypermedia-apis]: http://designinghypermediaapis.com/
|
||||
[restisover]: http://blog.steveklabnik.com/posts/2012-02-23-rest-is-over
|
||||
[readinglist]: http://blog.steveklabnik.com/posts/2012-02-27-hypermedia-api-reading-list
|
||||
[maturitymodel]: http://martinfowler.com/articles/richardsonMaturityModel.html
|
||||
|
||||
[collection]: http://www.amundsen.com/media-types/collection/
|
||||
[serialization]: ../api-guide/serializers.md
|
||||
[parser]: ../api-guide/parsers.md
|
||||
[renderer]: ../api-guide/renderers.md
|
||||
[fields]: ../api-guide/fields.md
|
||||
[conneg]: ../api-guide/content-negotiation.md
|
Loading…
Reference in New Issue
Block a user