aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore23
-rw-r--r--AUTHORS8
-rw-r--r--README99
-rw-r--r--README.rst132
-rw-r--r--djangorestframework/compat.py7
-rw-r--r--djangorestframework/mixins.py45
-rw-r--r--djangorestframework/parsers.py44
-rw-r--r--djangorestframework/renderers.py55
-rw-r--r--djangorestframework/resources.py50
-rw-r--r--djangorestframework/runtests/settings.py10
-rw-r--r--djangorestframework/serializer.py8
-rw-r--r--djangorestframework/templates/renderer.html8
-rw-r--r--djangorestframework/tests/oauthentication.py212
-rw-r--r--djangorestframework/tests/renderers.py98
-rw-r--r--djangorestframework/tests/reverse.py6
-rw-r--r--examples/requirements.txt6
-rw-r--r--requirements.txt2
17 files changed, 614 insertions, 199 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..4947943a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,23 @@
+*.pyc
+*.db
+assetplatform.egg-info/*
+*~
+coverage.xml
+env
+docs/build
+html
+htmlcov
+examples/media/pygments/[A-Za-z0-9]*
+examples/media/objectstore/[A-Za-z0-9]*
+build/*
+dist/*
+xmlrunner/*
+djangorestframework.egg-info/*
+MANIFEST
+.project
+.pydevproject
+.settings
+.cache
+.coverage
+.tox
+.DS_Store
diff --git a/AUTHORS b/AUTHORS
index a85b7ff8..d2f3611e 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -1,11 +1,15 @@
-Tom Christie <tomchristie> - tom@tomchristie.com, @thisneonsoul
-
+Tom Christie <tomchristie> - tom@tomchristie.com, @thisneonsoul - Author.
Paul Bagwell <pbgwl> - Suggestions & bugfixes.
Marko Tibold <markotibold> - Contributions & Providing the Jenkins CI Server.
Sébastien Piquemal <sebpiq> - Contributions.
Carmen Wick <cwick> - Bugfixes.
Alex Ehlke <aehlke> - Design Contributions.
Alen Mujezinovic <flashingpumpkin> - Contributions.
+Carles Barrobés <txels> - HEAD support.
+Michael Fötsch <mfoetsch> - File format support.
+David Larlet <david> - OAuth support.
+Andrew Straw <astraw> - Bugfixes.
+<zeth> - Bugfixes.
THANKS TO:
diff --git a/README b/README
deleted file mode 100644
index 3c740486..00000000
--- a/README
+++ /dev/null
@@ -1,99 +0,0 @@
-General Notes
--------------
-
-To install django-rest-framework in a virtualenv environment
-
- hg clone https://tomchristie@bitbucket.org/tomchristie/django-rest-framework
- cd django-rest-framework/
- virtualenv --no-site-packages --distribute --python=python2.6 env
- source ./env/bin/activate
- pip install -r requirements.txt # django, coverage
-
-
-To run the tests
-
- export PYTHONPATH=. # Ensure djangorestframework is on the PYTHONPATH
- python djangorestframework/runtests/runtests.py
-
-
-To run the test coverage report
-
- export PYTHONPATH=. # Ensure djangorestframework is on the PYTHONPATH
- python djangorestframework/runtests/runcoverage.py
-
-
-To run the examples
-
- pip install -r examples/requirements.txt # pygments, httplib2, markdown
- cd examples
- export PYTHONPATH=..
- python manage.py syncdb
- python manage.py runserver
-
-
-To build the documentation
-
- pip install -r docs/requirements.txt # sphinx
- sphinx-build -c docs -b html -d docs/build docs html
-
-
-To run the tests against the full set of supported configurations
-
- deactivate # Ensure we are not currently running in a virtualenv
- tox
-
-
-To create the sdist packages
-
- python setup.py sdist --formats=gztar,zip
-
-
-
-Release Notes
-=============
-
-0.2.3
-
- * Fix some throttling bugs
- * X-Throttle header on throttling
- * Support for nesting resources on related models
-
-0.2.2
-
- * Throttling support complete
-
-0.2.1
-
- * Couple of simple bugfixes over 0.2.0
-
-0.2.0
-
- * Big refactoring changes since 0.1.0, ask on the discussion group if anything isn't clear.
- The public API has been massively cleaned up. Expect it to be fairly stable from here on in.
-
- * `Resource` becomes decoupled into `View` and `Resource`, your views should now inherit from `View`, not `Resource`.
-
- * The handler functions on views .get() .put() .post() etc, no longer have the `content` and `auth` args.
- Use `self.CONTENT` inside a view to access the deserialized, validated content.
- Use `self.user` inside a view to access the authenticated user.
-
- * `allowed_methods` and `anon_allowed_methods` are now defunct. if a method is defined, it's available.
- The `permissions` attribute on a `View` is now used to provide generic permissions checking.
- Use permission classes such as `FullAnonAccess`, `IsAuthenticated` or `IsUserOrIsAnonReadOnly` to set the permissions.
-
- * The `authenticators` class becomes `authentication`. Class names change to Authentication.
-
- * The `emitters` class becomes `renderers`. Class names change to Renderers.
-
- * `ResponseException` becomes `ErrorResponse`.
-
- * The mixin classes have been nicely refactored, the basic mixins are now `RequestMixin`, `ResponseMixin`, `AuthMixin`, and `ResourceMixin`
- You can reuse these mixin classes individually without using the `View` class.
-
-0.1.1
-
- * Final build before pulling in all the refactoring changes for 0.2, in case anyone needs to hang on to 0.1.
-
-0.1.0
-
- * Initial release. \ No newline at end of file
diff --git a/README.rst b/README.rst
new file mode 100644
index 00000000..f5789ae0
--- /dev/null
+++ b/README.rst
@@ -0,0 +1,132 @@
+Django REST framework
+=====================
+
+Django REST framework makes it easy to build well-connected, self-describing RESTful Web APIs.
+
+Features:
+
+* Creates awesome self-describing *web browse-able* APIs.
+* Clean, modular design, using Django's class based views.
+* Easily extended for custom content types, serialization formats and authentication policies.
+* Stable, well tested code-base.
+* Active developer community.
+
+Full documentation for the project is available at http://django-rest-framework.org
+
+Issue tracking is on `GitHub <https://github.com/tomchristie/django-rest-framework/issues>`_.
+General questions should be taken to the `discussion group <http://groups.google.com/group/django-rest-framework>`_.
+
+Requirements:
+
+* Python (2.5, 2.6, 2.7 supported)
+* Django (1.2, 1.3 supported)
+
+
+Installation Notes
+==================
+
+To clone the project from GitHub using git::
+
+ git clone git@github.com:tomchristie/django-rest-framework.git
+
+
+To clone the project from Bitbucket using mercurial::
+
+ hg clone https://tomchristie@bitbucket.org/tomchristie/django-rest-framework
+
+
+To install django-rest-framework in a virtualenv environment::
+
+ cd django-rest-framework
+ virtualenv --no-site-packages --distribute --python=python2.6 env
+ source env/bin/activate
+ pip install -r requirements.txt # django, coverage
+
+
+To run the tests::
+
+ export PYTHONPATH=. # Ensure djangorestframework is on the PYTHONPATH
+ python djangorestframework/runtests/runtests.py
+
+
+To run the test coverage report::
+
+ export PYTHONPATH=. # Ensure djangorestframework is on the PYTHONPATH
+ python djangorestframework/runtests/runcoverage.py
+
+
+To run the examples::
+
+ pip install -r examples/requirements.txt # pygments, httplib2, markdown
+ cd examples
+ export PYTHONPATH=..
+ python manage.py syncdb
+ python manage.py runserver
+
+
+To build the documentation::
+
+ pip install -r docs/requirements.txt # sphinx
+ sphinx-build -c docs -b html -d docs/build docs html
+
+
+To run the tests against the full set of supported configurations::
+
+ deactivate # Ensure we are not currently running in a virtualenv
+ tox
+
+
+To create the sdist packages::
+
+ python setup.py sdist --formats=gztar,zip
+
+
+
+Release Notes
+=============
+
+0.2.3
+
+* Fix some throttling bugs.
+* ``X-Throttle`` header on throttling.
+* Support for nesting resources on related models.
+
+0.2.2
+
+* Throttling support complete.
+
+0.2.1
+
+* Couple of simple bugfixes over 0.2.0
+
+0.2.0
+
+* Big refactoring changes since 0.1.0, ask on the discussion group if anything isn't clear.
+ The public API has been massively cleaned up. Expect it to be fairly stable from here on in.
+
+* ``Resource`` becomes decoupled into ``View`` and ``Resource``, your views should now inherit from ``View``, not ``Resource``.
+
+* The handler functions on views ``.get() .put() .post()`` etc, no longer have the ``content`` and ``auth`` args.
+ Use ``self.CONTENT`` inside a view to access the deserialized, validated content.
+ Use ``self.user`` inside a view to access the authenticated user.
+
+* ``allowed_methods`` and ``anon_allowed_methods`` are now defunct. if a method is defined, it's available.
+ The ``permissions`` attribute on a ``View`` is now used to provide generic permissions checking.
+ Use permission classes such as ``FullAnonAccess``, ``IsAuthenticated`` or ``IsUserOrIsAnonReadOnly`` to set the permissions.
+
+* The ``authenticators`` class becomes ``authentication``. Class names change to ``Authentication``.
+
+* The ``emitters`` class becomes ``renderers``. Class names change to ``Renderers``.
+
+* ``ResponseException`` becomes ``ErrorResponse``.
+
+* The mixin classes have been nicely refactored, the basic mixins are now ``RequestMixin``, ``ResponseMixin``, ``AuthMixin``, and ``ResourceMixin``
+ You can reuse these mixin classes individually without using the ``View`` class.
+
+0.1.1
+
+* Final build before pulling in all the refactoring changes for 0.2, in case anyone needs to hang on to 0.1.
+
+0.1.0
+
+* Initial release. \ No newline at end of file
diff --git a/djangorestframework/compat.py b/djangorestframework/compat.py
index 827b4adf..230172c3 100644
--- a/djangorestframework/compat.py
+++ b/djangorestframework/compat.py
@@ -156,6 +156,7 @@ except ImportError:
def head(self, request, *args, **kwargs):
return self.get(request, *args, **kwargs)
+# Markdown is optional
try:
import markdown
import re
@@ -204,3 +205,9 @@ try:
except ImportError:
apply_markdown = None
+
+# Yaml is optional
+try:
+ import yaml
+except ImportError:
+ yaml = None
diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py
index 910d06ae..b1ba0596 100644
--- a/djangorestframework/mixins.py
+++ b/djangorestframework/mixins.py
@@ -11,6 +11,7 @@ from django.http.multipartparser import LimitBytes
from djangorestframework import status
from djangorestframework.parsers import FormParser, MultiPartParser
+from djangorestframework.renderers import BaseRenderer
from djangorestframework.resources import Resource, FormResource, ModelResource
from djangorestframework.response import Response, ErrorResponse
from djangorestframework.utils import as_tuple, MSIE_USER_AGENT_REGEX
@@ -290,7 +291,7 @@ class ResponseMixin(object):
accept_list = [token.strip() for token in request.META["HTTP_ACCEPT"].split(',')]
else:
# No accept header specified
- return (self._default_renderer(self), self._default_renderer.media_type)
+ accept_list = ['*/*']
# Check the acceptable media types against each renderer,
# attempting more specific media types first
@@ -298,12 +299,12 @@ class ResponseMixin(object):
# Worst case is we're looping over 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 accepted_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, media_type
-
+ for accepted_media_type in accepted_media_type_lst:
+ if renderer.can_handle_response(accepted_media_type):
+ return renderer, accepted_media_type
+
# No acceptable renderers were found
raise ErrorResponse(status.HTTP_406_NOT_ACCEPTABLE,
{'detail': 'Could not satisfy the client\'s Accept header',
@@ -316,6 +317,13 @@ class ResponseMixin(object):
Return an list of all the media types that this view can render.
"""
return [renderer.media_type for renderer in self.renderers]
+
+ @property
+ def _rendered_formats(self):
+ """
+ Return a list of all the formats that this view can render.
+ """
+ return [renderer.format for renderer in self.renderers]
@property
def _default_renderer(self):
@@ -483,14 +491,17 @@ class ReadModelMixin(object):
try:
if args:
# If we have any none kwargs then assume the last represents the primrary key
- instance = model.objects.get(pk=args[-1], **kwargs)
+ self.model_instance = model.objects.get(pk=args[-1], **kwargs)
else:
# Otherwise assume the kwargs uniquely identify the model
- instance = model.objects.get(**kwargs)
+ filtered_keywords = kwargs.copy()
+ if BaseRenderer._FORMAT_QUERY_PARAM in filtered_keywords:
+ del filtered_keywords[BaseRenderer._FORMAT_QUERY_PARAM]
+ self.model_instance = model.objects.get(**filtered_keywords)
except model.DoesNotExist:
raise ErrorResponse(status.HTTP_404_NOT_FOUND)
- return instance
+ return self.model_instance
class CreateModelMixin(object):
@@ -529,19 +540,19 @@ class UpdateModelMixin(object):
try:
if args:
# If we have any none kwargs then assume the last represents the primrary key
- instance = model.objects.get(pk=args[-1], **kwargs)
+ self.model_instance = model.objects.get(pk=args[-1], **kwargs)
else:
# Otherwise assume the kwargs uniquely identify the model
- instance = model.objects.get(**kwargs)
+ self.model_instance = model.objects.get(**kwargs)
for (key, val) in self.CONTENT.items():
- setattr(instance, key, val)
+ setattr(self.model_instance, key, val)
except model.DoesNotExist:
- instance = model(**self.CONTENT)
- instance.save()
+ self.model_instance = model(**self.CONTENT)
+ self.model_instance.save()
- instance.save()
- return instance
+ self.model_instance.save()
+ return self.model_instance
class DeleteModelMixin(object):
@@ -587,7 +598,7 @@ class ListModelMixin(object):
def get(self, request, *args, **kwargs):
model = self.resource.model
- queryset = self.queryset if self.queryset else model.objects.all()
+ queryset = self.queryset if self.queryset is not None else model.objects.all()
if hasattr(self, 'resource'):
ordering = getattr(self.resource, 'ordering', None)
diff --git a/djangorestframework/parsers.py b/djangorestframework/parsers.py
index a25ca89e..5f19c521 100644
--- a/djangorestframework/parsers.py
+++ b/djangorestframework/parsers.py
@@ -13,12 +13,13 @@ We need a method to be able to:
from django.http import QueryDict
from django.http.multipartparser import MultiPartParser as DjangoMultiPartParser
+from django.http.multipartparser import MultiPartParserError
from django.utils import simplejson as json
from djangorestframework import status
+from djangorestframework.compat import yaml
from djangorestframework.response import ErrorResponse
from djangorestframework.utils.mediatypes import media_type_matches
-import yaml
__all__ = (
'BaseParser',
@@ -87,25 +88,26 @@ class JSONParser(BaseParser):
{'detail': 'JSON parse error - %s' % unicode(exc)})
-class YAMLParser(BaseParser):
- """
- Parses YAML-serialized data.
- """
-
- media_type = 'application/yaml'
-
- def parse(self, stream):
+if yaml:
+ class YAMLParser(BaseParser):
"""
- Returns a 2-tuple of `(data, files)`.
-
- `data` will be an object which is the parsed content of the response.
- `files` will always be `None`.
+ Parses YAML-serialized data.
"""
- try:
- return (yaml.safe_load(stream), None)
- except ValueError, exc:
- raise ErrorResponse(status.HTTP_400_BAD_REQUEST,
- {'detail': 'YAML parse error - %s' % unicode(exc)})
+
+ media_type = 'application/yaml'
+
+ def parse(self, stream):
+ """
+ Returns a 2-tuple of `(data, files)`.
+
+ `data` will be an object which is the parsed content of the response.
+ `files` will always be `None`.
+ """
+ try:
+ return (yaml.safe_load(stream), None)
+ except ValueError, exc:
+ raise ErrorResponse(status.HTTP_400_BAD_REQUEST,
+ {'detail': 'YAML parse error - %s' % unicode(exc)})
class PlainTextParser(BaseParser):
@@ -158,6 +160,10 @@ class MultiPartParser(BaseParser):
`files` will be a :class:`QueryDict` containing all the form files.
"""
upload_handlers = self.view.request._get_upload_handlers()
- django_parser = DjangoMultiPartParser(self.view.request.META, stream, upload_handlers)
+ try:
+ django_parser = DjangoMultiPartParser(self.view.request.META, stream, upload_handlers)
+ except MultiPartParserError, exc:
+ raise ErrorResponse(status.HTTP_400_BAD_REQUEST,
+ {'detail': 'multipart parse error - %s' % unicode(exc)})
return django_parser.parse()
diff --git a/djangorestframework/renderers.py b/djangorestframework/renderers.py
index 18ffbf66..aae2cab2 100644
--- a/djangorestframework/renderers.py
+++ b/djangorestframework/renderers.py
@@ -12,7 +12,7 @@ from django.template import RequestContext, loader
from django.utils import simplejson as json
-from djangorestframework.compat import apply_markdown
+from djangorestframework.compat import apply_markdown, yaml
from djangorestframework.utils import dict2xml, url_resolves
from djangorestframework.utils.breadcrumbs import get_breadcrumbs
from djangorestframework.utils.description import get_name, get_description
@@ -21,7 +21,6 @@ from djangorestframework import VERSION
import string
from urllib import quote_plus
-import yaml
__all__ = (
'BaseRenderer',
@@ -40,8 +39,11 @@ class BaseRenderer(object):
All renderers must extend this class, set the :attr:`media_type` attribute,
and override the :meth:`render` method.
"""
+
+ _FORMAT_QUERY_PARAM = 'format'
media_type = None
+ format = None
def __init__(self, view):
self.view = view
@@ -58,6 +60,11 @@ class BaseRenderer(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.
"""
+ format = self.view.kwargs.get(self._FORMAT_QUERY_PARAM, None)
+ if format is 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)
def render(self, obj=None, media_type=None):
@@ -84,6 +91,7 @@ class JSONRenderer(BaseRenderer):
"""
media_type = 'application/json'
+ format = 'json'
def render(self, obj=None, media_type=None):
"""
@@ -111,6 +119,7 @@ class XMLRenderer(BaseRenderer):
"""
media_type = 'application/xml'
+ format = 'xml'
def render(self, obj=None, media_type=None):
"""
@@ -120,20 +129,27 @@ class XMLRenderer(BaseRenderer):
return ''
return dict2xml(obj)
-class YAMLRenderer(BaseRenderer):
- """
- Renderer which serializes to YAML.
- """
- media_type = 'application/yaml'
-
- def render(self, obj=None, media_type=None):
+if yaml:
+ class YAMLRenderer(BaseRenderer):
"""
- Renders *obj* into serialized YAML.
+ Renderer which serializes to YAML.
"""
- if obj is None:
- return ''
- return yaml.dump(obj)
+
+ media_type = 'application/yaml'
+ format = 'yaml'
+
+ def render(self, obj=None, media_type=None):
+ """
+ Renders *obj* into serialized YAML.
+ """
+ if obj is None:
+ return ''
+
+ return yaml.dump(obj)
+else:
+ YAMLRenderer = None
+
class TemplateRenderer(BaseRenderer):
"""
@@ -303,12 +319,12 @@ class DocumentingTemplateRenderer(BaseRenderer):
'version': VERSION,
'markeddown': markeddown,
'breadcrumblist': breadcrumb_list,
- 'available_media_types': self.view._rendered_media_types,
+ 'available_formats': self.view._rendered_formats,
'put_form': put_form_instance,
'post_form': post_form_instance,
'login_url': login_url,
'logout_url': logout_url,
- 'ACCEPT_PARAM': getattr(self.view, '_ACCEPT_QUERY_PARAM', None),
+ 'FORMAT_PARAM': self._FORMAT_QUERY_PARAM,
'METHOD_PARAM': getattr(self.view, '_METHOD_PARAM', None),
'ADMIN_MEDIA_PREFIX': settings.ADMIN_MEDIA_PREFIX
})
@@ -331,6 +347,7 @@ class DocumentingHTMLRenderer(DocumentingTemplateRenderer):
"""
media_type = 'text/html'
+ format = 'html'
template = 'renderer.html'
@@ -342,6 +359,7 @@ class DocumentingXHTMLRenderer(DocumentingTemplateRenderer):
"""
media_type = 'application/xhtml+xml'
+ format = 'xhtml'
template = 'renderer.html'
@@ -353,6 +371,7 @@ class DocumentingPlainTextRenderer(DocumentingTemplateRenderer):
"""
media_type = 'text/plain'
+ format = 'txt'
template = 'renderer.txt'
@@ -360,7 +379,7 @@ DEFAULT_RENDERERS = ( JSONRenderer,
DocumentingHTMLRenderer,
DocumentingXHTMLRenderer,
DocumentingPlainTextRenderer,
- XMLRenderer,
- YAMLRenderer )
-
+ XMLRenderer )
+if YAMLRenderer:
+ DEFAULT_RENDERERS += (YAMLRenderer,)
diff --git a/djangorestframework/resources.py b/djangorestframework/resources.py
index b42bd952..be361ab8 100644
--- a/djangorestframework/resources.py
+++ b/djangorestframework/resources.py
@@ -177,14 +177,12 @@ class FormResource(Resource):
# Return HTTP 400 response (BAD REQUEST)
raise ErrorResponse(400, detail)
-
- def get_bound_form(self, data=None, files=None, method=None):
+
+ def get_form_class(self, method=None):
"""
- Given some content return a Django form bound to that content.
- If form validation is turned off (:attr:`form` class attribute is :const:`None`) then returns :const:`None`.
+ Returns the form class used to validate this resource.
"""
-
# A form on the view overrides a form on the resource.
form = getattr(self.view, 'form', None) or self.form
@@ -200,6 +198,16 @@ class FormResource(Resource):
form = getattr(self, '%s_form' % method.lower(), form)
form = getattr(self.view, '%s_form' % method.lower(), form)
+ return form
+
+
+ def get_bound_form(self, data=None, files=None, method=None):
+ """
+ Given some content return a Django form bound to that content.
+ If form validation is turned off (:attr:`form` class attribute is :const:`None`) then returns :const:`None`.
+ """
+ form = self.get_form_class(method)
+
if not form:
return None
@@ -306,31 +314,31 @@ class ModelResource(FormResource):
If the :attr:`form` class attribute has been explicitly set then that class will be used
to create the Form, otherwise the model will be used to create a ModelForm.
"""
+ form = self.get_form_class(method)
- form = super(ModelResource, self).get_bound_form(data, files, method=method)
-
- # Use an explict Form if it exists
- if form:
- return form
-
- elif self.model:
+ if not form and self.model:
# Fall back to ModelForm which we create on the fly
class OnTheFlyModelForm(forms.ModelForm):
class Meta:
model = self.model
#fields = tuple(self._model_fields_set)
- # Instantiate the ModelForm as appropriate
- if data and isinstance(data, models.Model):
- # Bound to an existing model instance
- return OnTheFlyModelForm(instance=content)
- elif data is not None:
- return OnTheFlyModelForm(data, files)
- return OnTheFlyModelForm()
+ form = OnTheFlyModelForm
# Both form and model not set? Okay bruv, whatevs...
- return None
-
+ if not form:
+ return None
+
+ # Instantiate the ModelForm as appropriate
+ if data is not None or files is not None:
+ if issubclass(form, forms.ModelForm) and hasattr(self.view, 'model_instance'):
+ # Bound to an existing model instance
+ return form(data, files, instance=self.view.model_instance)
+ else:
+ return form(data, files)
+
+ return form()
+
def url(self, instance):
"""
diff --git a/djangorestframework/runtests/settings.py b/djangorestframework/runtests/settings.py
index 0cc7f4e3..9b3c2c92 100644
--- a/djangorestframework/runtests/settings.py
+++ b/djangorestframework/runtests/settings.py
@@ -2,6 +2,7 @@
DEBUG = True
TEMPLATE_DEBUG = DEBUG
+DEBUG_PROPAGATE_EXCEPTIONS = True
ADMINS = (
# ('Your Name', 'your_email@domain.com'),
@@ -96,6 +97,15 @@ INSTALLED_APPS = (
'djangorestframework',
)
+# OAuth support is optional, so we only test oauth if it's installed.
+try:
+ import oauth_provider
+except ImportError:
+ pass
+else:
+ INSTALLED_APPS += ('oauth_provider',)
+
+# If we're running on the Jenkins server we want to archive the coverage reports as XML.
import os
if os.environ.get('HUDSON_URL', None):
TEST_RUNNER = 'xmlrunner.extra.djangotestrunner.XMLTestRunner'
diff --git a/djangorestframework/serializer.py b/djangorestframework/serializer.py
index da8036e9..82aeb53f 100644
--- a/djangorestframework/serializer.py
+++ b/djangorestframework/serializer.py
@@ -4,7 +4,7 @@ Customizable serialization.
from django.db import models
from django.db.models.query import QuerySet
from django.db.models.fields.related import RelatedField
-from django.utils.encoding import smart_unicode, is_protected_type
+from django.utils.encoding import smart_unicode, is_protected_type, smart_str
import decimal
import inspect
@@ -177,7 +177,7 @@ class Serializer(object):
Keys serialize to their string value,
unless they exist in the `rename` dict.
"""
- return getattr(self.rename, key, key)
+ return getattr(self.rename, smart_str(key), smart_str(key))
def serialize_val(self, key, obj):
@@ -228,12 +228,12 @@ class Serializer(object):
# serialize each required field
for fname in fields:
- if hasattr(self, fname):
+ if hasattr(self, smart_str(fname)):
# check for a method 'fname' on self first
meth = getattr(self, fname)
if inspect.ismethod(meth) and len(inspect.getargspec(meth)[0]) == 2:
obj = meth(instance)
- elif hasattr(instance, fname):
+ elif hasattr(instance, smart_str(fname)):
# now check for an attribute 'fname' on the instance
obj = getattr(instance, fname)
elif fname in instance:
diff --git a/djangorestframework/templates/renderer.html b/djangorestframework/templates/renderer.html
index 44e032aa..3dd5faf3 100644
--- a/djangorestframework/templates/renderer.html
+++ b/djangorestframework/templates/renderer.html
@@ -50,9 +50,9 @@
<h2>GET {{ name }}</h2>
<div class='submit-row' style='margin: 0; border: 0'>
<a href='{{ request.get_full_path }}' rel="nofollow" style='float: left'>GET</a>
- {% for media_type in available_media_types %}
- {% with ACCEPT_PARAM|add:"="|add:media_type as param %}
- [<a href='{{ request.get_full_path|add_query_param:param }}' rel="nofollow">{{ media_type }}</a>]
+ {% for format in available_formats %}
+ {% with FORMAT_PARAM|add:"="|add:format as param %}
+ [<a href='{{ request.get_full_path|add_query_param:param }}' rel="nofollow">{{ format }}</a>]
{% endwith %}
{% endfor %}
</div>
@@ -124,4 +124,4 @@
</div>
</div>
</body>
-</html> \ No newline at end of file
+</html>
diff --git a/djangorestframework/tests/oauthentication.py b/djangorestframework/tests/oauthentication.py
new file mode 100644
index 00000000..109d9a72
--- /dev/null
+++ b/djangorestframework/tests/oauthentication.py
@@ -0,0 +1,212 @@
+import time
+
+from django.conf.urls.defaults import patterns, url, include
+from django.contrib.auth.models import User
+from django.test import Client, TestCase
+
+from djangorestframework.views import View
+
+# Since oauth2 / django-oauth-plus are optional dependancies, we don't want to
+# always run these tests.
+
+# Unfortunatly we can't skip tests easily until 2.7, se we'll just do this for now.
+try:
+ import oauth2 as oauth
+ from oauth_provider.decorators import oauth_required
+ from oauth_provider.models import Resource, Consumer, Token
+
+except ImportError:
+ pass
+
+else:
+ # Alrighty, we're good to go here.
+ class ClientView(View):
+ def get(self, request):
+ return {'resource': 'Protected!'}
+
+ urlpatterns = patterns('',
+ url(r'^$', oauth_required(ClientView.as_view())),
+ url(r'^oauth/', include('oauth_provider.urls')),
+ url(r'^accounts/login/$', 'djangorestframework.utils.staticviews.api_login'),
+ )
+
+
+ class OAuthTests(TestCase):
+ """
+ OAuth authentication:
+ * the user would like to access his API data from a third-party website
+ * the third-party website proposes a link to get that API data
+ * the user is redirected to the API and must log in if not authenticated
+ * the API displays a webpage to confirm that the user trusts the third-party website
+ * if confirmed, the user is redirected to the third-party website through the callback view
+ * the third-party website is able to retrieve data from the API
+ """
+ urls = 'djangorestframework.tests.oauthentication'
+
+ def setUp(self):
+ self.client = Client()
+ self.username = 'john'
+ self.email = 'lennon@thebeatles.com'
+ self.password = 'password'
+ self.user = User.objects.create_user(self.username, self.email, self.password)
+
+ # OAuth requirements
+ self.resource = Resource(name='data', url='/')
+ self.resource.save()
+ self.CONSUMER_KEY = 'dpf43f3p2l4k3l03'
+ self.CONSUMER_SECRET = 'kd94hf93k423kf44'
+ self.consumer = Consumer(key=self.CONSUMER_KEY, secret=self.CONSUMER_SECRET,
+ name='api.example.com', user=self.user)
+ self.consumer.save()
+
+ def test_oauth_invalid_and_anonymous_access(self):
+ """
+ Verify that the resource is protected and the OAuth authorization view
+ require the user to be logged in.
+ """
+ response = self.client.get('/')
+ self.assertEqual(response.content, 'Invalid request parameters.')
+ self.assertEqual(response.status_code, 401)
+ response = self.client.get('/oauth/authorize/', follow=True)
+ self.assertRedirects(response, '/accounts/login/?next=/oauth/authorize/')
+
+ def test_oauth_authorize_access(self):
+ """
+ Verify that once logged in, the user can access the authorization page
+ but can't display the page because the request token is not specified.
+ """
+ self.client.login(username=self.username, password=self.password)
+ response = self.client.get('/oauth/authorize/', follow=True)
+ self.assertEqual(response.content, 'No request token specified.')
+
+ def _create_request_token_parameters(self):
+ """
+ A shortcut to create request's token parameters.
+ """
+ return {
+ 'oauth_consumer_key': self.CONSUMER_KEY,
+ 'oauth_signature_method': 'PLAINTEXT',
+ 'oauth_signature': '%s&' % self.CONSUMER_SECRET,
+ 'oauth_timestamp': str(int(time.time())),
+ 'oauth_nonce': 'requestnonce',
+ 'oauth_version': '1.0',
+ 'oauth_callback': 'http://api.example.com/request_token_ready',
+ 'scope': 'data',
+ }
+
+ def test_oauth_request_token_retrieval(self):
+ """
+ Verify that the request token can be retrieved by the server.
+ """
+ response = self.client.get("/oauth/request_token/",
+ self._create_request_token_parameters())
+ self.assertEqual(response.status_code, 200)
+ token = list(Token.objects.all())[-1]
+ self.failIf(token.key not in response.content)
+ self.failIf(token.secret not in response.content)
+
+ def test_oauth_user_request_authorization(self):
+ """
+ Verify that the user can access the authorization page once logged in
+ and the request token has been retrieved.
+ """
+ # Setup
+ response = self.client.get("/oauth/request_token/",
+ self._create_request_token_parameters())
+ token = list(Token.objects.all())[-1]
+
+ # Starting the test here
+ self.client.login(username=self.username, password=self.password)
+ parameters = {'oauth_token': token.key,}
+ response = self.client.get("/oauth/authorize/", parameters)
+ self.assertEqual(response.status_code, 200)
+ self.failIf(not response.content.startswith('Fake authorize view for api.example.com with params: oauth_token='))
+ self.assertEqual(token.is_approved, 0)
+ parameters['authorize_access'] = 1 # fake authorization by the user
+ response = self.client.post("/oauth/authorize/", parameters)
+ self.assertEqual(response.status_code, 302)
+ self.failIf(not response['Location'].startswith('http://api.example.com/request_token_ready?oauth_verifier='))
+ token = Token.objects.get(key=token.key)
+ self.failIf(token.key not in response['Location'])
+ self.assertEqual(token.is_approved, 1)
+
+ def _create_access_token_parameters(self, token):
+ """
+ A shortcut to create access' token parameters.
+ """
+ return {
+ 'oauth_consumer_key': self.CONSUMER_KEY,
+ 'oauth_token': token.key,
+ 'oauth_signature_method': 'PLAINTEXT',
+ 'oauth_signature': '%s&%s' % (self.CONSUMER_SECRET, token.secret),
+ 'oauth_timestamp': str(int(time.time())),
+ 'oauth_nonce': 'accessnonce',
+ 'oauth_version': '1.0',
+ 'oauth_verifier': token.verifier,
+ 'scope': 'data',
+ }
+
+ def test_oauth_access_token_retrieval(self):
+ """
+ Verify that the request token can be retrieved by the server.
+ """
+ # Setup
+ response = self.client.get("/oauth/request_token/",
+ self._create_request_token_parameters())
+ token = list(Token.objects.all())[-1]
+ self.client.login(username=self.username, password=self.password)
+ parameters = {'oauth_token': token.key,}
+ response = self.client.get("/oauth/authorize/", parameters)
+ parameters['authorize_access'] = 1 # fake authorization by the user
+ response = self.client.post("/oauth/authorize/", parameters)
+ token = Token.objects.get(key=token.key)
+
+ # Starting the test here
+ response = self.client.get("/oauth/access_token/", self._create_access_token_parameters(token))
+ self.assertEqual(response.status_code, 200)
+ self.failIf(not response.content.startswith('oauth_token_secret='))
+ access_token = list(Token.objects.filter(token_type=Token.ACCESS))[-1]
+ self.failIf(access_token.key not in response.content)
+ self.failIf(access_token.secret not in response.content)
+ self.assertEqual(access_token.user.username, 'john')
+
+ def _create_access_parameters(self, access_token):
+ """
+ A shortcut to create access' parameters.
+ """
+ parameters = {
+ 'oauth_consumer_key': self.CONSUMER_KEY,
+ 'oauth_token': access_token.key,
+ 'oauth_signature_method': 'HMAC-SHA1',
+ 'oauth_timestamp': str(int(time.time())),
+ 'oauth_nonce': 'accessresourcenonce',
+ 'oauth_version': '1.0',
+ }
+ oauth_request = oauth.Request.from_token_and_callback(access_token,
+ http_url='http://testserver/', parameters=parameters)
+ signature_method = oauth.SignatureMethod_HMAC_SHA1()
+ signature = signature_method.sign(oauth_request, self.consumer, access_token)
+ parameters['oauth_signature'] = signature
+ return parameters
+
+ def test_oauth_protected_resource_access(self):
+ """
+ Verify that the request token can be retrieved by the server.
+ """
+ # Setup
+ response = self.client.get("/oauth/request_token/",
+ self._create_request_token_parameters())
+ token = list(Token.objects.all())[-1]
+ self.client.login(username=self.username, password=self.password)
+ parameters = {'oauth_token': token.key,}
+ response = self.client.get("/oauth/authorize/", parameters)
+ parameters['authorize_access'] = 1 # fake authorization by the user
+ response = self.client.post("/oauth/authorize/", parameters)
+ token = Token.objects.get(key=token.key)
+ response = self.client.get("/oauth/access_token/", self._create_access_token_parameters(token))
+ access_token = list(Token.objects.filter(token_type=Token.ACCESS))[-1]
+
+ # Starting the test here
+ response = self.client.get("/", self._create_access_token_parameters(access_token))
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.content, '{"resource": "Protected!"}')
diff --git a/djangorestframework/tests/renderers.py b/djangorestframework/tests/renderers.py
index 569eb640..d2046212 100644
--- a/djangorestframework/tests/renderers.py
+++ b/djangorestframework/tests/renderers.py
@@ -2,16 +2,17 @@ from django.conf.urls.defaults import patterns, url
from django import http
from django.test import TestCase
+from djangorestframework import status
from djangorestframework.compat import View as DjangoView
-from djangorestframework.renderers import BaseRenderer, JSONRenderer
-from djangorestframework.parsers import JSONParser
+from djangorestframework.renderers import BaseRenderer, JSONRenderer, YAMLRenderer
+from djangorestframework.parsers import JSONParser, YAMLParser
from djangorestframework.mixins import ResponseMixin
from djangorestframework.response import Response
from djangorestframework.utils.mediatypes import add_media_type_param
from StringIO import StringIO
-DUMMYSTATUS = 200
+DUMMYSTATUS = status.HTTP_200_OK
DUMMYCONTENT = 'dummycontent'
RENDERER_A_SERIALIZER = lambda x: 'Renderer A: %s' % x
@@ -19,12 +20,14 @@ RENDERER_B_SERIALIZER = lambda x: 'Renderer B: %s' % x
class RendererA(BaseRenderer):
media_type = 'mock/renderera'
+ format="formata"
def render(self, obj=None, media_type=None):
return RENDERER_A_SERIALIZER(obj)
class RendererB(BaseRenderer):
media_type = 'mock/rendererb'
+ format="formatb"
def render(self, obj=None, media_type=None):
return RENDERER_B_SERIALIZER(obj)
@@ -32,11 +35,13 @@ class RendererB(BaseRenderer):
class MockView(ResponseMixin, DjangoView):
renderers = (RendererA, RendererB)
- def get(self, request):
+ def get(self, request, **kwargs):
response = Response(DUMMYSTATUS, DUMMYCONTENT)
return self.render(response)
+
urlpatterns = patterns('',
+ url(r'^.*\.(?P<format>.+)$', MockView.as_view(renderers=[RendererA, RendererB])),
url(r'^$', MockView.as_view(renderers=[RendererA, RendererB])),
)
@@ -85,10 +90,58 @@ class RendererIntegrationTests(TestCase):
self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT))
self.assertEquals(resp.status_code, DUMMYSTATUS)
+ def test_specified_renderer_serializes_content_on_accept_query(self):
+ """The '_accept' query string should behave in the same way as the Accept header."""
+ resp = self.client.get('/?_accept=%s' % RendererB.media_type)
+ self.assertEquals(resp['Content-Type'], RendererB.media_type)
+ self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT))
+ self.assertEquals(resp.status_code, DUMMYSTATUS)
+
def test_unsatisfiable_accept_header_on_request_returns_406_status(self):
"""If the Accept header is unsatisfiable we should return a 406 Not Acceptable response."""
resp = self.client.get('/', HTTP_ACCEPT='foo/bar')
- self.assertEquals(resp.status_code, 406)
+ self.assertEquals(resp.status_code, status.HTTP_406_NOT_ACCEPTABLE)
+
+ def test_specified_renderer_serializes_content_on_format_query(self):
+ """If a 'format' query is specified, the renderer with the matching
+ format attribute should serialize the response."""
+ resp = self.client.get('/?format=%s' % RendererB.format)
+ self.assertEquals(resp['Content-Type'], RendererB.media_type)
+ self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT))
+ self.assertEquals(resp.status_code, DUMMYSTATUS)
+
+ def test_specified_renderer_serializes_content_on_format_kwargs(self):
+ """If a 'format' keyword arg is specified, the renderer with the matching
+ format attribute should serialize the response."""
+ resp = self.client.get('/something.formatb')
+ self.assertEquals(resp['Content-Type'], RendererB.media_type)
+ self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT))
+ self.assertEquals(resp.status_code, DUMMYSTATUS)
+
+ def test_specified_renderer_is_used_on_format_query_with_matching_accept(self):
+ """If both a 'format' query and a matching Accept header specified,
+ the renderer with the matching format attribute should serialize the response."""
+ resp = self.client.get('/?format=%s' % RendererB.format,
+ HTTP_ACCEPT=RendererB.media_type)
+ self.assertEquals(resp['Content-Type'], RendererB.media_type)
+ 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)
+
+ def test_bla(self):
+ resp = self.client.get('/?format=formatb',
+ HTTP_ACCEPT='text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8')
+ 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"]}'
@@ -136,3 +189,38 @@ class JSONRendererTests(TestCase):
content = renderer.render(obj, 'application/json')
(data, files) = parser.parse(StringIO(content))
self.assertEquals(obj, data)
+
+
+
+if YAMLRenderer:
+ _yaml_repr = 'foo: [bar, baz]\n'
+
+
+ class YAMLRendererTests(TestCase):
+ """
+ Tests specific to the JSON Renderer
+ """
+
+ def test_render(self):
+ """
+ Test basic YAML rendering.
+ """
+ obj = {'foo':['bar','baz']}
+ renderer = YAMLRenderer(None)
+ content = renderer.render(obj, 'application/yaml')
+ self.assertEquals(content, _yaml_repr)
+
+
+ def test_render_and_parse(self):
+ """
+ Test rendering and then parsing returns the original object.
+ IE obj -> render -> parse -> obj.
+ """
+ obj = {'foo':['bar','baz']}
+
+ renderer = YAMLRenderer(None)
+ parser = YAMLParser(None)
+
+ content = renderer.render(obj, 'application/yaml')
+ (data, files) = parser.parse(StringIO(content))
+ self.assertEquals(obj, data) \ No newline at end of file
diff --git a/djangorestframework/tests/reverse.py b/djangorestframework/tests/reverse.py
index b4b0a793..2d1ca79e 100644
--- a/djangorestframework/tests/reverse.py
+++ b/djangorestframework/tests/reverse.py
@@ -24,9 +24,5 @@ class ReverseTests(TestCase):
urls = 'djangorestframework.tests.reverse'
def test_reversed_urls_are_fully_qualified(self):
- try:
- response = self.client.get('/')
- except:
- import traceback
- traceback.print_exc()
+ response = self.client.get('/')
self.assertEqual(json.loads(response.content), 'http://testserver/another')
diff --git a/examples/requirements.txt b/examples/requirements.txt
index bfaf11d5..70371574 100644
--- a/examples/requirements.txt
+++ b/examples/requirements.txt
@@ -1,9 +1,7 @@
-# For the examples we need Django, pygments and httplib2...
+# Pygments for the code highlighting example,
+# markdown for the docstring -> auto-documentation
-Django==1.2.4
-wsgiref==0.1.2
Pygments==1.4
-httplib2==0.6.0
Markdown==2.0.3
diff --git a/requirements.txt b/requirements.txt
index c596890d..da076b79 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,6 @@
# We need Django. Duh.
+# coverage isn't strictly a requirement, but it's useful.
Django==1.2.4
wsgiref==0.1.2
coverage==3.4
-Pyyaml==3.10