Yowzers. Final big bunch of refactoring for 0.1 release. Now support Django 1.3's views, admin style api is all polished off, loads of tests, new test project for running the test. All sorts of goodness. Getting ready to push this out now.

This commit is contained in:
tom christie tom@tomchristie.com 2011-02-19 10:26:27 +00:00
parent b749b950a1
commit 805aa03ec1
58 changed files with 1738 additions and 739 deletions

8
AUTHORS Normal file
View File

@ -0,0 +1,8 @@
Project Owner...
Tom Christie <tomchristie> - tom@tomchristie.com
Thanks to...
Jesper Noehr <jespern> & the django-piston contributors for providing the starting point for this project.
Paul Bagwell <pbgwl> - Suggestions & bugfixes.

View File

@ -1,4 +0,0 @@
Thanks to...
Jesper Noehr & the django-piston contributors for providing the starting point for this project.
Paul Bagwell - Suggestions & bugfixes.

View File

@ -4,18 +4,23 @@ 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
pip install -r requirements.txt # django, pip
# To build the documentation...
# To run the tests...
pip install -r docs/requirements.txt
sphinx-build -c docs -b html -d docs/build docs html
cd testproject
export PYTHONPATH=..
python manage.py test djangorestframework
# To run the examples...
pip install -r examples/requirements.txt
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

View File

@ -1,17 +1,41 @@
from django.contrib.auth import authenticate
from django.middleware.csrf import CsrfViewMiddleware
from djangorestframework.utils import as_tuple
import base64
class AuthenticatorMixin(object):
authenticators = None
def authenticate(self, request):
"""Attempt to authenticate the request, returning an authentication context or None.
An authentication context may be any object, although in many cases it will be a User instance."""
# Attempt authentication against each authenticator in turn,
# and return None if no authenticators succeed in authenticating the request.
for authenticator in as_tuple(self.authenticators):
auth_context = authenticator(self).authenticate(request)
if auth_context:
return auth_context
return None
class BaseAuthenticator(object):
"""All authenticators should extend BaseAuthenticator."""
def __init__(self, resource):
"""Initialise the authenticator with the Resource instance as state,
in case the authenticator needs to access any metadata on the Resource object."""
self.resource = resource
def __init__(self, mixin):
"""Initialise the authenticator with the mixin instance as state,
in case the authenticator needs to access any metadata on the mixin object."""
self.mixin = mixin
def authenticate(self, request):
"""Authenticate the request and return the authentication context or None.
An authentication context might be something as simple as a User object, or it might
be some more complicated token, for example authentication tokens which are signed
against a particular set of permissions for a given user, over a given timeframe.
The default permission checking on Resource will use the allowed_methods attribute
for permissions if the authentication context is not None, and use anon_allowed_methods otherwise.
@ -38,7 +62,9 @@ class BasicAuthenticator(BaseAuthenticator):
class UserLoggedInAuthenticator(BaseAuthenticator):
"""Use Djagno's built-in request session for authentication."""
def authenticate(self, request):
if getattr(request, 'user', None) and request.user.is_active:
return request.user
if getattr(request, 'user', None) and request.user.is_active:
resp = CsrfViewMiddleware().process_view(request, None, (), {})
if resp is None: # csrf passed
return request.user
return None

View File

@ -0,0 +1,31 @@
from django.core.urlresolvers import resolve
from djangorestframework.description import get_name
def get_breadcrumbs(url):
"""Given a url returns a list of breadcrumbs, which are each a tuple of (name, url)."""
def breadcrumbs_recursive(url, breadcrumbs_list):
"""Add tuples of (name, url) to the breadcrumbs list, progressively chomping off parts of the url."""
# This is just like compsci 101 all over again...
try:
(view, unused_args, unused_kwargs) = resolve(url)
except:
pass
else:
if callable(view):
breadcrumbs_list.insert(0, (get_name(view), url))
if url == '':
# All done
return breadcrumbs_list
elif url.endswith('/'):
# Drop trailing slash off the end and continue to try to resolve more breadcrumbs
return breadcrumbs_recursive(url.rstrip('/'), breadcrumbs_list)
# Drop trailing non-slash off the end and continue to try to resolve more breadcrumbs
return breadcrumbs_recursive(url[:url.rfind('/') + 1], breadcrumbs_list)
return breadcrumbs_recursive(url, [])

View File

@ -0,0 +1,128 @@
"""Compatability module to provide support for backwards compatability with older versions of django/python"""
# django.test.client.RequestFactory (Django >= 1.3)
try:
from django.test.client import RequestFactory
except ImportError:
from django.test import Client
from django.core.handlers.wsgi import WSGIRequest
# From: http://djangosnippets.org/snippets/963/
# Lovely stuff
class RequestFactory(Client):
"""
Class that lets you create mock Request objects for use in testing.
Usage:
rf = RequestFactory()
get_request = rf.get('/hello/')
post_request = rf.post('/submit/', {'foo': 'bar'})
This class re-uses the django.test.client.Client interface, docs here:
http://www.djangoproject.com/documentation/testing/#the-test-client
Once you have a request object you can pass it to any view function,
just as if that view had been hooked up using a URLconf.
"""
def request(self, **request):
"""
Similar to parent class, but returns the request object as soon as it
has created it.
"""
environ = {
'HTTP_COOKIE': self.cookies,
'PATH_INFO': '/',
'QUERY_STRING': '',
'REQUEST_METHOD': 'GET',
'SCRIPT_NAME': '',
'SERVER_NAME': 'testserver',
'SERVER_PORT': 80,
'SERVER_PROTOCOL': 'HTTP/1.1',
}
environ.update(self.defaults)
environ.update(request)
return WSGIRequest(environ)
# django.views.generic.View (Django >= 1.3)
try:
from django.views.generic import View
except:
from django import http
from django.utils.functional import update_wrapper
# from django.utils.log import getLogger
# from django.utils.decorators import classonlymethod
# logger = getLogger('django.request') - We'll just drop support for logger if running Django <= 1.2
# Might be nice to fix this up sometime to allow djangorestframework.compat.View to match 1.3's View more closely
class View(object):
"""
Intentionally simple parent class for all views. Only implements
dispatch-by-method and simple sanity checking.
"""
http_method_names = ['get', 'post', 'put', 'delete', 'head', 'options', 'trace']
def __init__(self, **kwargs):
"""
Constructor. Called in the URLconf; can contain helpful extra
keyword arguments, and other things.
"""
# Go through keyword arguments, and either save their values to our
# instance, or raise an error.
for key, value in kwargs.iteritems():
setattr(self, key, value)
# @classonlymethod - We'll just us classmethod instead if running Django <= 1.2
@classmethod
def as_view(cls, **initkwargs):
"""
Main entry point for a request-response process.
"""
# sanitize keyword arguments
for key in initkwargs:
if key in cls.http_method_names:
raise TypeError(u"You tried to pass in the %s method name as a "
u"keyword argument to %s(). Don't do that."
% (key, cls.__name__))
if not hasattr(cls, key):
raise TypeError(u"%s() received an invalid keyword %r" % (
cls.__name__, key))
def view(request, *args, **kwargs):
self = cls(**initkwargs)
return self.dispatch(request, *args, **kwargs)
# take name and docstring from class
update_wrapper(view, cls, updated=())
# and possible attributes set by decorators
# like csrf_exempt from dispatch
update_wrapper(view, cls.dispatch, assigned=())
return view
def dispatch(self, request, *args, **kwargs):
# Try to dispatch to the right method; if a method doesn't exist,
# defer to the error handler. Also defer to the error handler if the
# request method isn't on the approved list.
if request.method.lower() in self.http_method_names:
handler = getattr(self, request.method.lower(), self.http_method_not_allowed)
else:
handler = self.http_method_not_allowed
self.request = request
self.args = args
self.kwargs = kwargs
return handler(request, *args, **kwargs)
def http_method_not_allowed(self, request, *args, **kwargs):
allowed_methods = [m for m in self.http_method_names if hasattr(self, m)]
#logger.warning('Method Not Allowed (%s): %s' % (request.method, request.path),
# extra={
# 'status_code': 405,
# 'request': self.request
# }
#)
return http.HttpResponseNotAllowed(allowed_methods)

View File

@ -0,0 +1,37 @@
"""Get a descriptive name and description for a view,
based on class name and docstring, and override-able by 'name' and 'description' attributes"""
import re
def get_name(view):
"""Return a name for the view.
If view has a name attribute, use that, otherwise use the view's class name, with 'CamelCaseNames' converted to 'Camel Case Names'."""
if getattr(view, 'name', None) is not None:
return view.name
if getattr(view, '__name__', None) is not None:
name = view.__name__
elif getattr(view, '__class__', None) is not None: # TODO: should be able to get rid of this case once refactoring to 1.3 class views is complete
name = view.__class__.__name__
else:
return ''
return re.sub('(((?<=[a-z])[A-Z])|([A-Z](?![A-Z]|$)))', ' \\1', name).strip()
def get_description(view):
"""Provide a description for the view.
By default this is the view's docstring with nice unindention applied."""
if getattr(view, 'description', None) is not None:
return getattr(view, 'description')
if getattr(view, '__doc__', None) is not None:
whitespace_counts = [len(line) - len(line.lstrip(' ')) for line in view.__doc__.splitlines()[1:] if line.lstrip()]
if whitespace_counts:
whitespace_pattern = '^' + (' ' * min(whitespace_counts))
return re.sub(re.compile(whitespace_pattern, re.MULTILINE), '', view.__doc__)
return view.__doc__
return ''

View File

@ -4,21 +4,145 @@ by serializing the output along with documentation regarding the Resource, outpu
and providing forms and links depending on the allowed methods, emitters and parsers on the Resource.
"""
from django.conf import settings
from django.http import HttpResponse
from django.template import RequestContext, loader
from django import forms
from djangorestframework.response import NoContent
from djangorestframework.response import NoContent, ResponseException, status
from djangorestframework.validators import FormValidatorMixin
from djangorestframework.utils import dict2xml, url_resolves
from djangorestframework.markdownwrapper import apply_markdown
from djangorestframework.breadcrumbs import get_breadcrumbs
from djangorestframework.content import OverloadedContentMixin
from djangorestframework.description import get_name, get_description
from urllib import quote_plus
import string
import re
from decimal import Decimal
try:
import json
except ImportError:
import simplejson as json
_MSIE_USER_AGENT = re.compile(r'^Mozilla/[0-9]+\.[0-9]+ \([^)]*; MSIE [0-9]+\.[0-9]+[a-z]?;[^)]*\)(?!.* Opera )')
class EmitterMixin(object):
ACCEPT_QUERY_PARAM = '_accept' # Allow override of Accept header in URL query params
REWRITE_IE_ACCEPT_HEADER = True
request = None
response = None
emitters = ()
def emit(self, response):
self.response = response
try:
emitter = self._determine_emitter(self.request)
except ResponseException, exc:
emitter = self.default_emitter
response = exc.response
# Serialize the response content
if response.has_content_body:
content = emitter(self).emit(output=response.cleaned_content)
else:
content = emitter(self).emit()
# Munge DELETE Response code to allow us to return content
# (Do this *after* we've rendered the template so that we include the normal deletion response code in the output)
if response.status == 204:
response.status = 200
# Build the HTTP Response
# TODO: Check if emitter.mimetype is underspecified, or if a content-type header has been set
resp = HttpResponse(content, mimetype=emitter.media_type, status=response.status)
for (key, val) in response.headers.items():
resp[key] = val
return resp
def _determine_emitter(self, request):
"""Return the appropriate emitter for the output, given the client's 'Accept' header,
and the content types that this Resource knows how to serve.
See: RFC 2616, Section 14 - http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html"""
if self.ACCEPT_QUERY_PARAM and request.GET.get(self.ACCEPT_QUERY_PARAM, None):
# Use _accept parameter override
accept_list = [request.GET.get(self.ACCEPT_QUERY_PARAM)]
elif self.REWRITE_IE_ACCEPT_HEADER and request.META.has_key('HTTP_USER_AGENT') and _MSIE_USER_AGENT.match(request.META['HTTP_USER_AGENT']):
accept_list = ['text/html', '*/*']
elif request.META.has_key('HTTP_ACCEPT'):
# Use standard HTTP Accept negotiation
accept_list = request.META["HTTP_ACCEPT"].split(',')
else:
# No accept header specified
return self.default_emitter
# Parse the accept header into a dict of {qvalue: set of media types}
# We ignore mietype parameters
accept_dict = {}
for token in accept_list:
components = token.split(';')
mimetype = components[0].strip()
qvalue = Decimal('1.0')
if len(components) > 1:
# Parse items that have a qvalue eg text/html;q=0.9
try:
(q, num) = components[-1].split('=')
if q == 'q':
qvalue = Decimal(num)
except:
# Skip malformed entries
continue
if accept_dict.has_key(qvalue):
accept_dict[qvalue].add(mimetype)
else:
accept_dict[qvalue] = set((mimetype,))
# Convert to a list of sets ordered by qvalue (highest first)
accept_sets = [accept_dict[qvalue] for qvalue in sorted(accept_dict.keys(), reverse=True)]
for accept_set in accept_sets:
# Return any exact match
for emitter in self.emitters:
if emitter.media_type in accept_set:
return emitter
# Return any subtype match
for emitter in self.emitters:
if emitter.media_type.split('/')[0] + '/*' in accept_set:
return emitter
# Return default
if '*/*' in accept_set:
return self.default_emitter
raise ResponseException(status.HTTP_406_NOT_ACCEPTABLE,
{'detail': 'Could not statisfy the client\'s Accept header',
'available_types': self.emitted_media_types})
@property
def emitted_media_types(self):
"""Return an list of all the media types that this resource can emit."""
return [emitter.media_type for emitter in self.emitters]
@property
def default_emitter(self):
"""Return the resource's most prefered emitter.
(This emitter is used if the client does not send and Accept: header, or sends Accept: */*)"""
return self.emitters[0]
# TODO: Rename verbose to something more appropriate
# TODO: NoContent could be handled more cleanly. It'd be nice if it was handled by default,
@ -51,7 +175,7 @@ class TemplateEmitter(BaseEmitter):
if output is NoContent:
return ''
context = RequestContext(self.resource.request, output)
context = RequestContext(self.request, output)
return self.template.render(context)
@ -60,7 +184,7 @@ class DocumentingTemplateEmitter(BaseEmitter):
Implementing classes should extend this class and set the template attribute."""
template = None
def _get_content(self, resource, output):
def _get_content(self, resource, request, output):
"""Get the content as if it had been emitted by a non-documenting emitter.
(Typically this will be the content as it would have been if the Resource had been
@ -88,21 +212,24 @@ class DocumentingTemplateEmitter(BaseEmitter):
form_instance = None
if isinstance(self, FormValidatorMixin):
# Otherwise if this isn't an error response
# then attempt to get a form bound to the response object
if isinstance(resource, FormValidatorMixin):
# If we already have a bound form instance (IE provided by the input parser, then use that)
if resource.bound_form_instance is not None:
form_instance = resource.bound_form_instance
# Otherwise if we have a response that is valid against the form then use that
if not form_instance and resource.response.has_content_body:
try:
form_instance = resource.get_bound_form(resource.response.raw_content)
if form_instance:
form_instance.is_valid()
if form_instance and not form_instance.is_valid():
form_instance = None
except:
form_instance = None
# If we still don't have a form instance then try to get an unbound form
if not form_instance:
try:
form_instance = self.resource.get_bound_form()
form_instance = resource.get_bound_form()
except:
pass
@ -117,6 +244,11 @@ class DocumentingTemplateEmitter(BaseEmitter):
"""Returns a form that allows for arbitrary content types to be tunneled via standard HTML forms
(Which are typically application/x-www-form-urlencoded)"""
# If we're not using content overloading there's no point in supplying a generic form,
# as the resource won't treat the form's value as the content of the request.
if not isinstance(resource, OverloadedContentMixin):
return None
# NB. http://jacobian.org/writing/dynamic-form-generation/
class GenericContentForm(forms.Form):
def __init__(self, resource):
@ -143,7 +275,7 @@ class DocumentingTemplateEmitter(BaseEmitter):
def emit(self, output=NoContent):
content = self._get_content(self.resource, output)
content = self._get_content(self.resource, self.resource.request, output)
form_instance = self._get_form_instance(self.resource)
if url_resolves(settings.LOGIN_URL) and url_resolves(settings.LOGOUT_URL):
@ -153,24 +285,36 @@ class DocumentingTemplateEmitter(BaseEmitter):
login_url = None
logout_url = None
name = get_name(self.resource)
description = get_description(self.resource)
markeddown = None
if apply_markdown:
try:
markeddown = apply_markdown(description)
except AttributeError: # TODO: possibly split the get_description / get_name into a mixin class
markeddown = None
breadcrumb_list = get_breadcrumbs(self.resource.request.path)
template = loader.get_template(self.template)
context = RequestContext(self.resource.request, {
'content': content,
'resource': self.resource,
'request': self.resource.request,
'response': self.resource.response,
'description': description,
'name': name,
'markeddown': markeddown,
'breadcrumblist': breadcrumb_list,
'form': form_instance,
'login_url': login_url,
'logout_url': logout_url,
'ADMIN_MEDIA_PREFIX': settings.ADMIN_MEDIA_PREFIX
})
ret = template.render(context)
# Munge DELETE Response code to allow us to return content
# (Do this *after* we've rendered the template so that we include the normal deletion response code in the output)
if self.resource.response.status == 204:
self.resource.response.status = 200
return ret
@ -217,5 +361,11 @@ class DocumentingPlainTextEmitter(DocumentingTemplateEmitter):
Useful for browsing an API with command line tools."""
media_type = 'text/plain'
template = 'emitter.txt'
DEFAULT_EMITTERS = ( JSONEmitter,
DocumentingHTMLEmitter,
DocumentingXHTMLEmitter,
DocumentingPlainTextEmitter,
XMLEmitter )

View File

@ -0,0 +1,51 @@
"""If python-markdown is installed expose an apply_markdown(text) function,
to convert markeddown text into html. Otherwise just set apply_markdown to None.
See: http://www.freewisdom.org/projects/python-markdown/
"""
__all__ = ['apply_markdown']
try:
import markdown
import re
class CustomSetextHeaderProcessor(markdown.blockprocessors.BlockProcessor):
"""Override markdown's SetextHeaderProcessor, so that ==== headers are <h2> and ---- headers are <h3>.
We use <h1> for the resource name."""
# Detect Setext-style header. Must be first 2 lines of block.
RE = re.compile(r'^.*?\n[=-]{3,}', re.MULTILINE)
def test(self, parent, block):
return bool(self.RE.match(block))
def run(self, parent, blocks):
lines = blocks.pop(0).split('\n')
# Determine level. ``=`` is 1 and ``-`` is 2.
if lines[1].startswith('='):
level = 2
else:
level = 3
h = markdown.etree.SubElement(parent, 'h%d' % level)
h.text = lines[0].strip()
if len(lines) > 2:
# Block contains additional lines. Add to master blocks for later.
blocks.insert(0, '\n'.join(lines[2:]))
def apply_markdown(text):
"""Simple wrapper around markdown.markdown to apply our CustomSetextHeaderProcessor,
and also set the base level of '#' style headers to <h2>."""
extensions = ['headerid(level=2)']
safe_mode = False,
output_format = markdown.DEFAULT_OUTPUT_FORMAT
md = markdown.Markdown(extensions=markdown.load_extensions(extensions),
safe_mode=safe_mode,
output_format=output_format)
md.parser.blockprocessors['setextheader'] = CustomSetextHeaderProcessor(md.parser)
return md.convert(text)
except:
apply_markdown = None

View File

@ -4,13 +4,14 @@ from django.db.models import Model
from djangorestframework.response import status, Response, ResponseException
from djangorestframework.resource import Resource
from djangorestframework.validators import ModelFormValidatorMixin
import decimal
import inspect
import re
class ModelResource(Resource):
class ModelResource(Resource, ModelFormValidatorMixin):
"""A specialized type of Resource, for resources that map directly to a Django Model.
Useful things this provides:
@ -40,50 +41,50 @@ class ModelResource(Resource):
# By default the set of input fields will be the same as the set of output fields
# If you wish to override this behaviour you should explicitly set the
# form_fields attribute on your class.
form_fields = None
#form_fields = None
def get_form(self, content=None):
"""Return a form that may be used in validation and/or rendering an html emitter"""
if self.form:
return super(self.__class__, self).get_form(content)
elif self.model:
class NewModelForm(ModelForm):
class Meta:
model = self.model
fields = self.form_fields if self.form_fields else None
if content and isinstance(content, Model):
return NewModelForm(instance=content)
elif content:
return NewModelForm(content)
return NewModelForm()
return None
#def get_form(self, content=None):
# """Return a form that may be used in validation and/or rendering an html emitter"""
# if self.form:
# return super(self.__class__, self).get_form(content)
#
# elif self.model:
#
# class NewModelForm(ModelForm):
# class Meta:
# model = self.model
# fields = self.form_fields if self.form_fields else None
#
# if content and isinstance(content, Model):
# return NewModelForm(instance=content)
# elif content:
# return NewModelForm(content)
#
# return NewModelForm()
#
# return None
def cleanup_request(self, data, form_instance):
"""Override cleanup_request to drop read-only fields from the input prior to validation.
This ensures that we don't error out with 'non-existent field' when these fields are supplied,
and allows for a pragmatic approach to resources which include read-only elements.
I would actually like to be strict and verify the value of correctness of the values in these fields,
although that gets tricky as it involves validating at the point that we get the model instance.
See here for another example of this approach:
http://fedoraproject.org/wiki/Cloud_APIs_REST_Style_Guide
https://www.redhat.com/archives/rest-practices/2010-April/thread.html#00041"""
read_only_fields = set(self.fields) - set(self.form_instance.fields)
input_fields = set(data.keys())
clean_data = {}
for key in input_fields - read_only_fields:
clean_data[key] = data[key]
return super(ModelResource, self).cleanup_request(clean_data, form_instance)
#def cleanup_request(self, data, form_instance):
# """Override cleanup_request to drop read-only fields from the input prior to validation.
# This ensures that we don't error out with 'non-existent field' when these fields are supplied,
# and allows for a pragmatic approach to resources which include read-only elements.
#
# I would actually like to be strict and verify the value of correctness of the values in these fields,
# although that gets tricky as it involves validating at the point that we get the model instance.
#
# See here for another example of this approach:
# http://fedoraproject.org/wiki/Cloud_APIs_REST_Style_Guide
# https://www.redhat.com/archives/rest-practices/2010-April/thread.html#00041"""
# read_only_fields = set(self.fields) - set(self.form_instance.fields)
# input_fields = set(data.keys())
#
# clean_data = {}
# for key in input_fields - read_only_fields:
# clean_data[key] = data[key]
#
# return super(ModelResource, self).cleanup_request(clean_data, form_instance)
def cleanup_response(self, data):
@ -121,7 +122,7 @@ class ModelResource(Resource):
if inspect.ismethod(f) and len(inspect.getargspec(f)[0]) == 1:
ret = _any(f())
else:
ret = unicode(thing) # TRC TODO: Change this back!
ret = str(thing) # TRC TODO: Change this back!
return ret
@ -308,9 +309,9 @@ class ModelResource(Resource):
try: ret['absolute_url'] = data.get_absolute_url()
except: pass
for key, val in ret.items():
if key.endswith('_url') or key.endswith('_uri'):
ret[key] = self.add_domain(val)
#for key, val in ret.items():
# if key.endswith('_url') or key.endswith('_uri'):
# ret[key] = self.add_domain(val)
return ret
@ -346,7 +347,7 @@ class ModelResource(Resource):
instance.save()
headers = {}
if hasattr(instance, 'get_absolute_url'):
headers['Location'] = self.add_domain(instance.get_absolute_url())
headers['Location'] = instance.get_absolute_url()
return Response(status.HTTP_201_CREATED, instance, headers)
def get(self, request, auth, *args, **kwargs):

View File

@ -117,13 +117,7 @@ class FormParser(BaseParser):
return data
# TODO: Allow parsers to specify multiple media types
# TODO: Allow parsers to specify multiple media_types
class MultipartParser(FormParser):
"""The default parser for multipart form data.
Return a dict containing a single value for each non-reserved parameter.
"""
media_type = 'multipart/form-data'

View File

@ -1,15 +1,16 @@
from django.contrib.sites.models import Site
from django.core.urlresolvers import reverse
from django.http import HttpResponse
from django.core.urlresolvers import set_script_prefix
from django.views.decorators.csrf import csrf_exempt
from djangorestframework.compat import View
from djangorestframework.emitters import EmitterMixin
from djangorestframework.parsers import ParserMixin
from djangorestframework.authenticators import AuthenticatorMixin
from djangorestframework.validators import FormValidatorMixin
from djangorestframework.content import OverloadedContentMixin
from djangorestframework.methods import OverloadedPOSTMethodMixin
from djangorestframework import emitters, parsers, authenticators
from djangorestframework.response import status, Response, ResponseException
from decimal import Decimal
import re
# TODO: Figure how out references and named urls need to work nicely
@ -21,10 +22,10 @@ import re
__all__ = ['Resource']
_MSIE_USER_AGENT = re.compile(r'^Mozilla/[0-9]+\.[0-9]+ \([^)]*; MSIE [0-9]+\.[0-9]+[a-z]?;[^)]*\)(?!.* Opera )')
class Resource(ParserMixin, FormValidatorMixin, OverloadedContentMixin, OverloadedPOSTMethodMixin):
class Resource(EmitterMixin, ParserMixin, AuthenticatorMixin, FormValidatorMixin,
OverloadedContentMixin, OverloadedPOSTMethodMixin, View):
"""Handles incoming requests and maps them to REST operations,
performing authentication, input deserialization, input validation, output serialization."""
@ -52,67 +53,21 @@ class Resource(ParserMixin, FormValidatorMixin, OverloadedContentMixin, Overload
# Optional form for input validation and presentation of HTML formatted responses.
form = None
# Allow name and description for the Resource to be set explicitly,
# overiding the default classname/docstring behaviour.
# These are used for documentation in the standard html and text emitters.
name = None
description = None
# Map standard HTTP methods to function calls
callmap = { 'GET': 'get', 'POST': 'post',
'PUT': 'put', 'DELETE': 'delete' }
# Some reserved parameters to allow us to use standard HTML forms with our resource
# Override any/all of these with None to disable them, or override them with another value to rename them.
ACCEPT_QUERY_PARAM = '_accept' # Allow override of Accept header in URL query params CONTENTTYPE_PARAM = '_contenttype' # Allow override of Content-Type header in form params (allows sending arbitrary content with standard forms)
CSRF_PARAM = 'csrfmiddlewaretoken' # Django's CSRF token used in form params
_MUNGE_IE_ACCEPT_HEADER = True
def __new__(cls, *args, **kwargs):
"""Make the class callable so it can be used as a Django view."""
self = object.__new__(cls)
if args:
request = args[0]
self.__init__(request)
return self._handle_request(request, *args[1:], **kwargs)
else:
self.__init__()
return self
def __init__(self, request=None):
""""""
# Setup the resource context
self.request = request
self.response = None
self.form_instance = None
# These sets are determined now so that overridding classes can modify the various parameter names,
# or set them to None to disable them.
self.RESERVED_FORM_PARAMS = set((self.METHOD_PARAM, self.CONTENTTYPE_PARAM, self.CONTENT_PARAM, self.CSRF_PARAM))
self.RESERVED_QUERY_PARAMS = set((self.ACCEPT_QUERY_PARAM))
self.RESERVED_FORM_PARAMS.discard(None)
self.RESERVED_QUERY_PARAMS.discard(None)
@property
def name(self):
"""Provide a name for the resource.
By default this is the class name, with 'CamelCaseNames' converted to 'Camel Case Names'."""
class_name = self.__class__.__name__
return re.sub('(((?<=[a-z])[A-Z])|([A-Z](?![A-Z]|$)))', ' \\1', class_name).strip()
@property
def description(self):
"""Provide a description for the resource.
By default this is the class's docstring with leading line spaces stripped."""
return re.sub(re.compile('^ +', re.MULTILINE), '', self.__doc__)
@property
def emitted_media_types(self):
"""Return an list of all the media types that this resource can emit."""
return [emitter.media_type for emitter in self.emitters]
@property
def default_emitter(self):
"""Return the resource's most prefered emitter.
(This emitter is used if the client does not send and Accept: header, or sends Accept: */*)"""
return self.emitters[0]
def get(self, request, auth, *args, **kwargs):
"""Must be subclassed to be implemented."""
@ -134,12 +89,6 @@ class Resource(ParserMixin, FormValidatorMixin, OverloadedContentMixin, Overload
self.not_implemented('DELETE')
def reverse(self, view, *args, **kwargs):
"""Return a fully qualified URI for a given view or resource.
Add the domain using the Sites framework if possible, otherwise fallback to using the current request."""
return self.add_domain(reverse(view, args=args, kwargs=kwargs))
def not_implemented(self, operation):
"""Return an HTTP 500 server error if an operation is called which has been allowed by
allowed_methods, but which has not been implemented."""
@ -147,36 +96,6 @@ class Resource(ParserMixin, FormValidatorMixin, OverloadedContentMixin, Overload
{'detail': '%s operation on this resource has not been implemented' % (operation, )})
def add_domain(self, path):
"""Given a path, return an fully qualified URI.
Use the Sites framework if possible, otherwise fallback to using the domain from the current request."""
# Note that out-of-the-box the Sites framework uses the reserved domain 'example.com'
# See RFC 2606 - http://www.faqs.org/rfcs/rfc2606.html
try:
site = Site.objects.get_current()
if site.domain and site.domain != 'example.com':
return 'http://%s%s' % (site.domain, path)
except:
pass
return self.request.build_absolute_uri(path)
def authenticate(self, request):
"""Attempt to authenticate the request, returning an authentication context or None.
An authentication context may be any object, although in many cases it will be a User instance."""
# Attempt authentication against each authenticator in turn,
# and return None if no authenticators succeed in authenticating the request.
for authenticator in self.authenticators:
auth_context = authenticator(self).authenticate(request)
if auth_context:
return auth_context
return None
def check_method_allowed(self, method, auth):
"""Ensure the request method is permitted for this resource, raising a ResourceException if it is not."""
@ -198,76 +117,16 @@ class Resource(ParserMixin, FormValidatorMixin, OverloadedContentMixin, Overload
"""Perform any resource-specific data filtering prior to the standard HTTP
content-type serialization.
Eg filter complex objects that cannot be serialized by json/xml/etc into basic objects that can."""
Eg filter complex objects that cannot be serialized by json/xml/etc into basic objects that can.
TODO: This is going to be removed. I think that the 'fields' behaviour is going to move into
the EmitterMixin and Emitter classes."""
return data
# Session based authentication is explicitly CSRF validated, all other authentication is CSRF exempt.
def determine_emitter(self, request):
"""Return the appropriate emitter for the output, given the client's 'Accept' header,
and the content types that this Resource knows how to serve.
See: RFC 2616, Section 14 - http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html"""
if self.ACCEPT_QUERY_PARAM and request.GET.get(self.ACCEPT_QUERY_PARAM, None):
# Use _accept parameter override
accept_list = [request.GET.get(self.ACCEPT_QUERY_PARAM)]
elif self._MUNGE_IE_ACCEPT_HEADER and request.META.has_key('HTTP_USER_AGENT') and _MSIE_USER_AGENT.match(request.META['HTTP_USER_AGENT']):
accept_list = ['text/html', '*/*']
elif request.META.has_key('HTTP_ACCEPT'):
# Use standard HTTP Accept negotiation
accept_list = request.META["HTTP_ACCEPT"].split(',')
else:
# No accept header specified
return self.default_emitter
# Parse the accept header into a dict of {qvalue: set of media types}
# We ignore mietype parameters
accept_dict = {}
for token in accept_list:
components = token.split(';')
mimetype = components[0].strip()
qvalue = Decimal('1.0')
if len(components) > 1:
# Parse items that have a qvalue eg text/html;q=0.9
try:
(q, num) = components[-1].split('=')
if q == 'q':
qvalue = Decimal(num)
except:
# Skip malformed entries
continue
if accept_dict.has_key(qvalue):
accept_dict[qvalue].add(mimetype)
else:
accept_dict[qvalue] = set((mimetype,))
# Convert to a list of sets ordered by qvalue (highest first)
accept_sets = [accept_dict[qvalue] for qvalue in sorted(accept_dict.keys(), reverse=True)]
for accept_set in accept_sets:
# Return any exact match
for emitter in self.emitters:
if emitter.media_type in accept_set:
return emitter
# Return any subtype match
for emitter in self.emitters:
if emitter.media_type.split('/')[0] + '/*' in accept_set:
return emitter
# Return default
if '*/*' in accept_set:
return self.default_emitter
raise ResponseException(status.HTTP_406_NOT_ACCEPTABLE,
{'detail': 'Could not statisfy the client\'s Accept header',
'available_types': self.emitted_media_types})
def _handle_request(self, request, *args, **kwargs):
@csrf_exempt
def dispatch(self, request, *args, **kwargs):
"""This method is the core of Resource, through which all requests are passed.
Broadly this consists of the following procedure:
@ -279,12 +138,23 @@ class Resource(ParserMixin, FormValidatorMixin, OverloadedContentMixin, Overload
4. cleanup the response data
5. serialize response data into response content, using standard HTTP content negotiation
"""
emitter = None
self.request = request
# Calls to 'reverse' will not be fully qualified unless we set the scheme/host/port here.
prefix = '%s://%s' % (request.is_secure() and 'https' or 'http', request.get_host())
set_script_prefix(prefix)
# These sets are determined now so that overridding classes can modify the various parameter names,
# or set them to None to disable them.
self.RESERVED_FORM_PARAMS = set((self.METHOD_PARAM, self.CONTENTTYPE_PARAM, self.CONTENT_PARAM, self.CSRF_PARAM))
self.RESERVED_QUERY_PARAMS = set((self.ACCEPT_QUERY_PARAM))
self.RESERVED_FORM_PARAMS.discard(None)
self.RESERVED_QUERY_PARAMS.discard(None)
method = self.determine_method(request)
try:
# Before we attempt anything else determine what format to emit our response data with.
emitter = self.determine_emitter(request)
# Authenticate the request, and store any context so that the resource operations can
# do more fine grained authentication if required.
@ -301,51 +171,34 @@ class Resource(ParserMixin, FormValidatorMixin, OverloadedContentMixin, Overload
func = getattr(self, self.callmap.get(method, None))
# Either generate the response data, deserializing and validating any request data
# TODO: Add support for message bodys on other HTTP methods, as it is valid.
# TODO: Add support for message bodys on other HTTP methods, as it is valid (although non-conventional).
if method in ('PUT', 'POST'):
(content_type, content) = self.determine_content(request)
parser_content = self.parse(content_type, content)
cleaned_content = self.validate(parser_content)
response = func(request, auth_context, cleaned_content, *args, **kwargs)
response_obj = func(request, auth_context, cleaned_content, *args, **kwargs)
else:
response = func(request, auth_context, *args, **kwargs)
response_obj = func(request, auth_context, *args, **kwargs)
# Allow return value to be either Response, or an object, or None
if isinstance(response, Response):
self.response = response
elif response is not None:
self.response = Response(status.HTTP_200_OK, response)
if isinstance(response_obj, Response):
response = response_obj
elif response_obj is not None:
response = Response(status.HTTP_200_OK, response_obj)
else:
self.response = Response(status.HTTP_204_NO_CONTENT)
response = Response(status.HTTP_204_NO_CONTENT)
# Pre-serialize filtering (eg filter complex objects into natively serializable types)
self.response.cleaned_content = self.cleanup_response(self.response.raw_content)
response.cleaned_content = self.cleanup_response(response.raw_content)
except ResponseException, exc:
self.response = exc.response
# Fall back to the default emitter if we failed to perform content negotiation
if emitter is None:
emitter = self.default_emitter
response = exc.response
# Always add these headers
self.response.headers['Allow'] = ', '.join(self.allowed_methods)
self.response.headers['Vary'] = 'Authenticate, Allow'
response.headers['Allow'] = ', '.join(self.allowed_methods)
response.headers['Vary'] = 'Authenticate, Allow'
# Serialize the response content
if self.response.has_content_body:
content = emitter(self).emit(output=self.response.cleaned_content)
else:
content = emitter(self).emit()
# Build the HTTP Response
# TODO: Check if emitter.mimetype is underspecified, or if a content-type header has been set
resp = HttpResponse(content, mimetype=emitter.media_type, status=self.response.status)
for (key, val) in self.response.headers.items():
resp[key] = val
return resp
return self.emit(response)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View File

@ -0,0 +1,48 @@
<html>
<head>
<link rel="stylesheet" type="text/css" href='{{ADMIN_MEDIA_PREFIX}}css/base.css'/>
<link rel="stylesheet" type="text/css" href='{{ADMIN_MEDIA_PREFIX}}css/forms.css'/>
<link rel="stylesheet" type="text/css" href='{{ADMIN_MEDIA_PREFIX}}css/login.css' />
<style>
.form-row {border-bottom: 0.25em !important}</style>
</head>
<body class="login">
<div id="container">
<div id="header">
<div id="branding">
<h1 id="site-name">Django REST framework</h1>
</div>
</div>
<div id="content" class="colM">
<div id="content-main">
<form method="post" action="{% url djangorestframework.views.api_login %}" id="login-form">
{% csrf_token %}
<div class="form-row">
<label for="id_username">Username:</label> {{ form.username }}
</div>
<div class="form-row">
<label for="id_password">Password:</label> {{ form.password }}
<input type="hidden" name="next" value="{{ next }}" />
</div>
<div class="form-row">
<label>&nbsp;</label><input type="submit" value="Log in">
</div>
</form>
<script type="text/javascript">
document.getElementById('id_username').focus()
</script>
</div>
<br class="clear">
</div>
<div id="footer"></div>
</div>
</body>
</html>

View File

@ -3,49 +3,59 @@
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<style>
pre {border: 1px solid black; padding: 1em; background: #ffd}
body {margin: 0; border:0; padding: 0;}
span.api {margin: 0.5em 1em}
span.auth {float: right; margin-right: 1em}
div.header {margin: 0; border:0; padding: 0.25em 0; background: #ddf}
div.content {margin: 0 1em;}
div.action {border: 1px solid black; padding: 0.5em 1em; margin-bottom: 0.5em; background: #ddf}
ul.accepttypes {float: right; list-style-type: none; margin: 0; padding: 0}
ul.accepttypes li {display: inline;}
form div {margin: 0.5em 0}
form div * {vertical-align: top}
form ul.errorlist {display: inline; margin: 0; padding: 0}
form ul.errorlist li {display: inline; color: red;}
.clearing {display: block; margin: 0; padding: 0; clear: both;}
</style>
<title>API - {{ resource.name }}</title>
<style>
/* Override some of the Django admin styling */
#site-name a {color: #F4F379 !important;}
.errorlist {display: inline !important}
.errorlist li {display: inline !important; background: white !important; color: black !important; border: 0 !important;}
</style>
<link rel="stylesheet" type="text/css" href='{{ADMIN_MEDIA_PREFIX}}css/base.css'/>
<link rel="stylesheet" type="text/css" href='{{ADMIN_MEDIA_PREFIX}}css/forms.css'/>
<title>Django REST framework - {{ name }}</title>
</head>
<body>
<div class='header'>
<span class='api'><a href='http://django-rest-framework.org'>Django REST framework</a></span>
<span class='auth'>{% if user.is_active %}Welcome, {{ user }}.{% if logout_url %} <a href='{{ logout_url }}'>Log out</a>{% endif %}{% else %}Not logged in {% if login_url %}<a href='{{ login_url }}'>Log in</a>{% endif %}{% endif %}</span>
<div id="container">
<div id="header">
<div id="branding">
<h1 id="site-name"><a href='http://django-rest-framework.org'>Django REST framework</a></h1>
</div>
<div id="user-tools">
{% if user.is_active %}Welcome, {{ user }}.{% if logout_url %} <a href='{{ logout_url }}'>Log out</a>{% endif %}{% else %}Anonymous {% if login_url %}<a href='{{ login_url }}'>Log in</a>{% endif %}{% endif %}
</div>
</div>
<div class='content'>
<h1>{{ resource.name }}</h1>
<p>{{ resource.description|linebreaksbr }}</p>
<div class="breadcrumbs">
{% for breadcrumb_name, breadcrumb_url in breadcrumblist %}
<a href="{{breadcrumb_url}}">{{breadcrumb_name}}</a> {% if not forloop.last %}&rsaquo;{% endif %}
{% endfor %}
</div>
<div id="content" class="{% block coltype %}colM{% endblock %}">
<div class='content-main'>
<h1>{{ name }}</h1>
<p>{% if markeddown %}{% autoescape off %}{{ markeddown }}{% endautoescape %}{% else %}{{ description|linebreaksbr }}{% endif %}</p>
<div class='module'>
<pre><b>{{ response.status }} {{ response.status_text }}</b>{% autoescape off %}
{% for key, val in response.headers.items %}<b>{{ key }}:</b> {{ val|urlize_quoted_links }}
{% endfor %}
{{ content|urlize_quoted_links }}</pre>{% endautoescape %}
{{ content|urlize_quoted_links }}</pre>{% endautoescape %}</div>
{% if 'GET' in resource.allowed_methods %}
<div class='action'>
<a href='{{ request.path }}' rel="nofollow">GET</a>
<ul class="accepttypes">
{% for media_type in resource.emitted_media_types %}
{% with resource.ACCEPT_QUERY_PARAM|add:"="|add:media_type as param %}
<li>[<a href='{{ request.path|add_query_param:param }}' rel="nofollow">{{ media_type }}</a>]</li>
{% endwith %}
{% endfor %}
</ul>
<div class="clearing"></div>
</div>
<form>
<fieldset class='module aligned'>
<h2>GET {{ name }}</h2>
<div class='submit-row' style='margin: 0; border: 0'>
<a href='{{ request.path }}' rel="nofollow" style='float: left'>GET</a>
{% for media_type in resource.emitted_media_types %}
{% with resource.ACCEPT_QUERY_PARAM|add:"="|add:media_type as param %}
[<a href='{{ request.path|add_query_param:param }}' rel="nofollow">{{ media_type }}</a>]
{% endwith %}
{% endfor %}
</div>
</fieldset>
</form>
{% endif %}
{% comment %} *** Only display the POST/PUT/DELETE forms if we have a bound form, and if method ***
@ -55,54 +65,63 @@
{% if resource.METHOD_PARAM and form %}
{% if 'POST' in resource.allowed_methods %}
<div class='action'>
<form action="{{ request.path }}" method="post">
<fieldset class='module aligned'>
<h2>POST {{ name }}</h2>
{% csrf_token %}
{{ form.non_field_errors }}
{% for field in form %}
<div>
{{ field.label_tag }}:
<div class='form-row'>
{{ field.label_tag }}
{{ field }}
{{ field.help_text }}
<span class='help'>{{ field.help_text }}</span>
{{ field.errors }}
</div>
{% endfor %}
<div class="clearing"></div>
<input type="submit" value="POST" />
<div class='submit-row' style='margin: 0; border: 0'>
<input type="submit" value="POST" class="default" />
</div>
</fieldset>
</form>
</div>
{% endif %}
{% if 'PUT' in resource.allowed_methods %}
<div class='action'>
<form action="{{ request.path }}" method="post">
<fieldset class='module aligned'>
<h2>PUT {{ name }}</h2>
<input type="hidden" name="{{ resource.METHOD_PARAM }}" value="PUT" />
{% csrf_token %}
{{ form.non_field_errors }}
{% for field in form %}
<div>
{{ field.label_tag }}:
<div class='form-row'>
{{ field.label_tag }}
{{ field }}
{{ field.help_text }}
<span class='help'>{{ field.help_text }}</span>
{{ field.errors }}
</div>
{% endfor %}
<div class="clearing"></div>
<input type="submit" value="PUT" />
<div class='submit-row' style='margin: 0; border: 0'>
<input type="submit" value="PUT" class="default" />
</div>
</fieldset>
</form>
</div>
{% endif %}
{% if 'DELETE' in resource.allowed_methods %}
<div class='action'>
<form action="{{ request.path }}" method="post">
<fieldset class='module aligned'>
<h2>DELETE {{ name }}</h2>
{% csrf_token %}
<input type="hidden" name="{{ resource.METHOD_PARAM }}" value="DELETE" />
<input type="submit" value="DELETE" />
<div class='submit-row' style='margin: 0; border: 0'>
<input type="submit" value="DELETE" class="default" />
</div>
</fieldset>
</form>
</div>
{% endif %}
{% endif %}
</div>
</div>
</div>
</body>
</html>

View File

@ -1,6 +1,6 @@
{{ resource.name }}
{{ name }}
{{ resource.description }}
{{ description }}
{% autoescape off %}HTTP/1.0 {{ response.status }} {{ response.status_text }}
{% for key, val in response.headers.items %}{{ key }}: {{ val }}

View File

@ -1,98 +0,0 @@
from django.test import Client, TestCase
from django.core.handlers.wsgi import WSGIRequest
from djangorestframework.resource import Resource
# From: http://djangosnippets.org/snippets/963/
class RequestFactory(Client):
"""
Class that lets you create mock Request objects for use in testing.
Usage:
rf = RequestFactory()
get_request = rf.get('/hello/')
post_request = rf.post('/submit/', {'foo': 'bar'})
This class re-uses the django.test.client.Client interface, docs here:
http://www.djangoproject.com/documentation/testing/#the-test-client
Once you have a request object you can pass it to any view function,
just as if that view had been hooked up using a URLconf.
"""
def request(self, **request):
"""
Similar to parent class, but returns the request object as soon as it
has created it.
"""
environ = {
'HTTP_COOKIE': self.cookies,
'PATH_INFO': '/',
'QUERY_STRING': '',
'REQUEST_METHOD': 'GET',
'SCRIPT_NAME': '',
'SERVER_NAME': 'testserver',
'SERVER_PORT': 80,
'SERVER_PROTOCOL': 'HTTP/1.1',
}
environ.update(self.defaults)
environ.update(request)
return WSGIRequest(environ)
# 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'
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"""
def setUp(self):
class MockResource(Resource):
anon_allowed_methods = allowed_methods = ('GET',)
def get(self, request, auth):
return {'a':1, 'b':2, 'c':3}
self.rf = RequestFactory()
self.MockResource = MockResource
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.rf.get('/', HTTP_ACCEPT='*/*', HTTP_USER_AGENT=user_agent)
resp = self.MockResource(req)
self.assertEqual(resp['Content-Type'], 'text/html')
def test_dont_munge_msie_accept_header(self):
"""Turn off _MUNGE_IE_ACCEPT_HEADER, send MSIE user agent strings and ensure
that we get a JSON response if we set a */* accept header."""
self.MockResource._MUNGE_IE_ACCEPT_HEADER = False
for user_agent in (MSIE_9_USER_AGENT,
MSIE_8_USER_AGENT,
MSIE_7_USER_AGENT):
req = self.rf.get('/', HTTP_ACCEPT='*/*', HTTP_USER_AGENT=user_agent)
resp = self.MockResource(req)
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.rf.get('/', HTTP_ACCEPT='*/*', HTTP_USER_AGENT=user_agent)
resp = self.MockResource(req)
self.assertEqual(resp['Content-Type'], 'application/json')

View File

@ -1,5 +1,5 @@
from django.test import TestCase
from djangorestframework.tests.utils import RequestFactory
from djangorestframework.compat import RequestFactory
from djangorestframework.resource import Resource
@ -24,6 +24,7 @@ class UserAgentMungingTest(TestCase):
return {'a':1, 'b':2, 'c':3}
self.req = RequestFactory()
self.MockResource = MockResource
self.view = MockResource.as_view()
def test_munge_msie_accept_header(self):
"""Send MSIE user agent strings and ensure that we get an HTML response,
@ -32,19 +33,19 @@ class UserAgentMungingTest(TestCase):
MSIE_8_USER_AGENT,
MSIE_7_USER_AGENT):
req = self.req.get('/', HTTP_ACCEPT='*/*', HTTP_USER_AGENT=user_agent)
resp = self.MockResource(req)
resp = self.view(req)
self.assertEqual(resp['Content-Type'], 'text/html')
def test_dont_munge_msie_accept_header(self):
"""Turn off _MUNGE_IE_ACCEPT_HEADER, send MSIE user agent strings and ensure
def test_dont_rewrite_msie_accept_header(self):
"""Turn off REWRITE_IE_ACCEPT_HEADER, send MSIE user agent strings and ensure
that we get a JSON response if we set a */* accept header."""
self.MockResource._MUNGE_IE_ACCEPT_HEADER = False
view = self.MockResource.as_view(REWRITE_IE_ACCEPT_HEADER=False)
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.MockResource(req)
resp = view(req)
self.assertEqual(resp['Content-Type'], 'application/json')
def test_dont_munge_nice_browsers_accept_header(self):
@ -56,7 +57,7 @@ class UserAgentMungingTest(TestCase):
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.MockResource(req)
resp = self.view(req)
self.assertEqual(resp['Content-Type'], 'application/json')

View File

@ -0,0 +1,90 @@
from django.conf.urls.defaults import patterns
from django.test import TestCase
from django.test import Client
from djangorestframework.compat import RequestFactory
from djangorestframework.resource import Resource
from django.contrib.auth.models import User
from django.contrib.auth import login
import base64
try:
import json
except ImportError:
import simplejson as json
class MockResource(Resource):
allowed_methods = ('POST',)
def post(self, request, auth, content):
return {'a':1, 'b':2, 'c':3}
urlpatterns = patterns('',
(r'^$', MockResource.as_view()),
)
class BasicAuthTests(TestCase):
"""Basic authentication"""
urls = 'djangorestframework.tests.authentication'
def setUp(self):
self.csrf_client = Client(enforce_csrf_checks=True)
self.username = 'john'
self.email = 'lennon@thebeatles.com'
self.password = 'password'
self.user = User.objects.create_user(self.username, self.email, self.password)
def test_post_form_passing_basic_auth(self):
"""Ensure POSTing json over basic auth with correct credentials passes and does not require CSRF"""
auth = 'Basic %s' % base64.encodestring('%s:%s' % (self.username, self.password)).strip()
response = self.csrf_client.post('/', {'example': 'example'}, HTTP_AUTHORIZATION=auth)
self.assertEqual(response.status_code, 200)
def test_post_json_passing_basic_auth(self):
"""Ensure POSTing form over basic auth with correct credentials passes and does not require CSRF"""
auth = 'Basic %s' % base64.encodestring('%s:%s' % (self.username, self.password)).strip()
response = self.csrf_client.post('/', json.dumps({'example': 'example'}), 'application/json', HTTP_AUTHORIZATION=auth)
self.assertEqual(response.status_code, 200)
def test_post_form_failing_basic_auth(self):
"""Ensure POSTing form over basic auth without correct credentials fails"""
response = self.csrf_client.post('/', {'example': 'example'})
self.assertEqual(response.status_code, 403)
def test_post_json_failing_basic_auth(self):
"""Ensure POSTing json over basic auth without correct credentials fails"""
response = self.csrf_client.post('/', json.dumps({'example': 'example'}), 'application/json')
self.assertEqual(response.status_code, 403)
class SessionAuthTests(TestCase):
"""User session authentication"""
urls = 'djangorestframework.tests.authentication'
def setUp(self):
self.csrf_client = Client(enforce_csrf_checks=True)
self.non_csrf_client = Client(enforce_csrf_checks=False)
self.username = 'john'
self.email = 'lennon@thebeatles.com'
self.password = 'password'
self.user = User.objects.create_user(self.username, self.email, self.password)
def tearDown(self):
self.csrf_client.logout()
def test_post_form_session_auth_failing_csrf(self):
"""Ensure POSTing form over session authentication without CSRF token fails."""
self.csrf_client.login(username=self.username, password=self.password)
response = self.csrf_client.post('/', {'example': 'example'})
self.assertEqual(response.status_code, 403)
def test_post_form_session_auth_passing(self):
"""Ensure POSTing form over session authentication with logged in user and CSRF token passes."""
self.non_csrf_client.login(username=self.username, password=self.password)
response = self.non_csrf_client.post('/', {'example': 'example'})
self.assertEqual(response.status_code, 200)
def test_post_form_session_auth_failing(self):
"""Ensure POSTing form over session authentication without logged in user fails."""
response = self.csrf_client.post('/', {'example': 'example'})
self.assertEqual(response.status_code, 403)

View File

@ -0,0 +1,67 @@
from django.conf.urls.defaults import patterns, url
from django.test import TestCase
from djangorestframework.breadcrumbs import get_breadcrumbs
from djangorestframework.resource import Resource
class Root(Resource):
pass
class ResourceRoot(Resource):
pass
class ResourceInstance(Resource):
pass
class NestedResourceRoot(Resource):
pass
class NestedResourceInstance(Resource):
pass
urlpatterns = patterns('',
url(r'^$', Root),
url(r'^resource/$', ResourceRoot),
url(r'^resource/(?P<key>[0-9]+)$', ResourceInstance),
url(r'^resource/(?P<key>[0-9]+)/$', NestedResourceRoot),
url(r'^resource/(?P<key>[0-9]+)/(?P<other>[A-Za-z]+)$', NestedResourceInstance),
)
class BreadcrumbTests(TestCase):
"""Tests the breadcrumb functionality used by the HTML emitter."""
urls = 'djangorestframework.tests.breadcrumbs'
def test_root_breadcrumbs(self):
url = '/'
self.assertEqual(get_breadcrumbs(url), [('Root', '/')])
def test_resource_root_breadcrumbs(self):
url = '/resource/'
self.assertEqual(get_breadcrumbs(url), [('Root', '/'),
('Resource Root', '/resource/')])
def test_resource_instance_breadcrumbs(self):
url = '/resource/123'
self.assertEqual(get_breadcrumbs(url), [('Root', '/'),
('Resource Root', '/resource/'),
('Resource Instance', '/resource/123')])
def test_nested_resource_breadcrumbs(self):
url = '/resource/123/'
self.assertEqual(get_breadcrumbs(url), [('Root', '/'),
('Resource Root', '/resource/'),
('Resource Instance', '/resource/123'),
('Nested Resource Root', '/resource/123/')])
def test_nested_resource_instance_breadcrumbs(self):
url = '/resource/123/abc'
self.assertEqual(get_breadcrumbs(url), [('Root', '/'),
('Resource Root', '/resource/'),
('Resource Instance', '/resource/123'),
('Nested Resource Root', '/resource/123/'),
('Nested Resource Instance', '/resource/123/abc')])
def test_broken_url_breadcrumbs_handled_gracefully(self):
url = '/foobar'
self.assertEqual(get_breadcrumbs(url), [('Root', '/')])

View File

@ -1,5 +1,5 @@
from django.test import TestCase
from djangorestframework.tests.utils import RequestFactory
from djangorestframework.compat import RequestFactory
from djangorestframework.content import ContentMixin, StandardContentMixin, OverloadedContentMixin

View File

@ -0,0 +1,93 @@
from django.test import TestCase
from djangorestframework.resource import Resource
from djangorestframework.markdownwrapper import apply_markdown
from djangorestframework.description import get_name, get_description
# We check that docstrings get nicely un-indented.
DESCRIPTION = """an example docstring
====================
* list
* list
another header
--------------
code block
indented
# hash style header #"""
# If markdown is installed we also test it's working (and that our wrapped forces '=' to h2 and '-' to h3)
MARKED_DOWN = """<h2>an example docstring</h2>
<ul>
<li>list</li>
<li>list</li>
</ul>
<h3>another header</h3>
<pre><code>code block
</code></pre>
<p>indented</p>
<h2 id="hash_style_header">hash style header</h2>"""
class TestResourceNamesAndDescriptions(TestCase):
def test_resource_name_uses_classname_by_default(self):
"""Ensure Resource names are based on the classname by default."""
class MockResource(Resource):
pass
self.assertEquals(get_name(MockResource()), 'Mock Resource')
def test_resource_name_can_be_set_explicitly(self):
"""Ensure Resource names can be set using the 'name' class attribute."""
example = 'Some Other Name'
class MockResource(Resource):
name = example
self.assertEquals(get_name(MockResource()), example)
def test_resource_description_uses_docstring_by_default(self):
"""Ensure Resource names are based on the docstring by default."""
class MockResource(Resource):
"""an example docstring
====================
* list
* list
another header
--------------
code block
indented
# hash style header #"""
self.assertEquals(get_description(MockResource()), DESCRIPTION)
def test_resource_description_can_be_set_explicitly(self):
"""Ensure Resource descriptions can be set using the 'description' class attribute."""
example = 'Some other description'
class MockResource(Resource):
"""docstring"""
description = example
self.assertEquals(get_description(MockResource()), example)
def test_resource_description_does_not_require_docstring(self):
"""Ensure that empty docstrings do not affect the Resource's description if it has been set using the 'description' class attribute."""
example = 'Some other description'
class MockResource(Resource):
description = example
self.assertEquals(get_description(MockResource()), example)
def test_resource_description_can_be_empty(self):
"""Ensure that if a resource has no doctring or 'description' class attribute, then it's description is the empty string"""
class MockResource(Resource):
pass
self.assertEquals(get_description(MockResource()), '')
def test_markdown(self):
"""Ensure markdown to HTML works as expected"""
if apply_markdown:
self.assertEquals(apply_markdown(DESCRIPTION), MARKED_DOWN)

View File

@ -0,0 +1,75 @@
from django.conf.urls.defaults import patterns, url
from django import http
from django.test import TestCase
from djangorestframework.compat import View
from djangorestframework.emitters import EmitterMixin, BaseEmitter
from djangorestframework.response import Response
DUMMYSTATUS = 200
DUMMYCONTENT = 'dummycontent'
EMITTER_A_SERIALIZER = lambda x: 'Emitter A: %s' % x
EMITTER_B_SERIALIZER = lambda x: 'Emitter B: %s' % x
class MockView(EmitterMixin, View):
def get(self, request):
response = Response(DUMMYSTATUS, DUMMYCONTENT)
return self.emit(response)
class EmitterA(BaseEmitter):
media_type = 'mock/emittera'
def emit(self, output, verbose=False):
return EMITTER_A_SERIALIZER(output)
class EmitterB(BaseEmitter):
media_type = 'mock/emitterb'
def emit(self, output, verbose=False):
return EMITTER_B_SERIALIZER(output)
urlpatterns = patterns('',
url(r'^$', MockView.as_view(emitters=[EmitterA, EmitterB])),
)
class EmitterIntegrationTests(TestCase):
"""End-to-end testing of emitters using an EmitterMixin on a generic view."""
urls = 'djangorestframework.tests.emitters'
def test_default_emitter_serializes_content(self):
"""If the Accept header is not set the default emitter should serialize the response."""
resp = self.client.get('/')
self.assertEquals(resp['Content-Type'], EmitterA.media_type)
self.assertEquals(resp.content, EMITTER_A_SERIALIZER(DUMMYCONTENT))
self.assertEquals(resp.status_code, DUMMYSTATUS)
def test_default_emitter_serializes_content_on_accept_any(self):
"""If the Accept header is set to */* the default emitter should serialize the response."""
resp = self.client.get('/', HTTP_ACCEPT='*/*')
self.assertEquals(resp['Content-Type'], EmitterA.media_type)
self.assertEquals(resp.content, EMITTER_A_SERIALIZER(DUMMYCONTENT))
self.assertEquals(resp.status_code, DUMMYSTATUS)
def test_specified_emitter_serializes_content_default_case(self):
"""If the Accept header is set the specified emitter should serialize the response.
(In this case we check that works for the default emitter)"""
resp = self.client.get('/', HTTP_ACCEPT=EmitterA.media_type)
self.assertEquals(resp['Content-Type'], EmitterA.media_type)
self.assertEquals(resp.content, EMITTER_A_SERIALIZER(DUMMYCONTENT))
self.assertEquals(resp.status_code, DUMMYSTATUS)
def test_specified_emitter_serializes_content_non_default_case(self):
"""If the Accept header is set the specified emitter should serialize the response.
(In this case we check that works for a non-default emitter)"""
resp = self.client.get('/', HTTP_ACCEPT=EmitterB.media_type)
self.assertEquals(resp['Content-Type'], EmitterB.media_type)
self.assertEquals(resp.content, EMITTER_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)

View File

@ -1,5 +1,5 @@
from django.test import TestCase
from djangorestframework.tests.utils import RequestFactory
from djangorestframework.compat import RequestFactory
from djangorestframework.methods import MethodMixin, StandardMethodMixin, OverloadedPOSTMethodMixin

View File

@ -1,25 +1,16 @@
from django.test import TestCase
from djangorestframework.response import Response
try:
import unittest2
except:
unittest2 = None
else:
import warnings
warnings.filterwarnings("ignore")
if unittest2:
class TestResponse(TestCase, unittest2.TestCase):
# Interface tests
# This is mainly to remind myself that the Response interface needs to change slightly
@unittest2.expectedFailure
def test_response_interface(self):
"""Ensure the Response interface is as expected."""
response = Response()
getattr(response, 'status')
getattr(response, 'content')
getattr(response, 'headers')
class TestResponse(TestCase):
# Interface tests
# This is mainly to remind myself that the Response interface needs to change slightly
def test_response_interface(self):
"""Ensure the Response interface is as expected."""
response = Response()
getattr(response, 'status')
getattr(response, 'content')
getattr(response, 'headers')

View File

@ -0,0 +1,32 @@
from django.conf.urls.defaults import patterns, url
from django.core.urlresolvers import reverse
from django.test import TestCase
from djangorestframework.resource import Resource
try:
import json
except ImportError:
import simplejson as json
class MockResource(Resource):
"""Mock resource which simply returns a URL, so that we can ensure that reversed URLs are fully qualified"""
anon_allowed_methods = ('GET',)
def get(self, request, auth):
return reverse('another')
urlpatterns = patterns('',
url(r'^$', MockResource.as_view()),
url(r'^another$', MockResource.as_view(), name='another'),
)
class ReverseTests(TestCase):
"""Tests for """
urls = 'djangorestframework.tests.reverse'
def test_reversed_urls_are_fully_qualified(self):
response = self.client.get('/')
self.assertEqual(json.loads(response.content), 'http://testserver/another')

View File

@ -1,40 +0,0 @@
from django.test import Client
from django.core.handlers.wsgi import WSGIRequest
# From: http://djangosnippets.org/snippets/963/
# Lovely stuff
class RequestFactory(Client):
"""
Class that lets you create mock Request objects for use in testing.
Usage:
rf = RequestFactory()
get_request = rf.get('/hello/')
post_request = rf.post('/submit/', {'foo': 'bar'})
This class re-uses the django.test.client.Client interface, docs here:
http://www.djangoproject.com/documentation/testing/#the-test-client
Once you have a request object you can pass it to any view function,
just as if that view had been hooked up using a URLconf.
"""
def request(self, **request):
"""
Similar to parent class, but returns the request object as soon as it
has created it.
"""
environ = {
'HTTP_COOKIE': self.cookies,
'PATH_INFO': '/',
'QUERY_STRING': '',
'REQUEST_METHOD': 'GET',
'SCRIPT_NAME': '',
'SERVER_NAME': 'testserver',
'SERVER_PORT': 80,
'SERVER_PROTOCOL': 'HTTP/1.1',
}
environ.update(self.defaults)
environ.update(request)
return WSGIRequest(environ)

View File

@ -1,134 +1,75 @@
from django import forms
from django.db import models
from django.test import TestCase
from djangorestframework.tests.utils import RequestFactory
from djangorestframework.compat import RequestFactory
from djangorestframework.validators import ValidatorMixin, FormValidatorMixin, ModelFormValidatorMixin
from djangorestframework.response import ResponseException
class TestValidatorMixins(TestCase):
def setUp(self):
self.req = RequestFactory()
class MockForm(forms.Form):
qwerty = forms.CharField(required=True)
class MockValidator(FormValidatorMixin):
form = MockForm
class DisabledValidator(FormValidatorMixin):
form = None
self.MockValidator = MockValidator
self.DisabledValidator = DisabledValidator
# Interface tests
class TestValidatorMixinInterfaces(TestCase):
"""Basic tests to ensure that the ValidatorMixin classes expose the expected interfaces"""
def test_validator_mixin_interface(self):
"""Ensure the ContentMixin interface is as expected."""
"""Ensure the ValidatorMixin base class interface is as expected."""
self.assertRaises(NotImplementedError, ValidatorMixin().validate, None)
def test_form_validator_mixin_interface(self):
"""Ensure the OverloadedContentMixin interface is as expected."""
"""Ensure the FormValidatorMixin interface is as expected."""
self.assertTrue(issubclass(FormValidatorMixin, ValidatorMixin))
getattr(FormValidatorMixin, 'form')
getattr(FormValidatorMixin, 'validate')
def test_model_form_validator_mixin_interface(self):
"""Ensure the OverloadedContentMixin interface is as expected."""
"""Ensure the ModelFormValidatorMixin interface is as expected."""
self.assertTrue(issubclass(ModelFormValidatorMixin, FormValidatorMixin))
getattr(ModelFormValidatorMixin, 'model')
getattr(ModelFormValidatorMixin, 'form')
getattr(ModelFormValidatorMixin, 'fields')
getattr(ModelFormValidatorMixin, 'exclude_fields')
getattr(ModelFormValidatorMixin, 'validate')
# Behavioural tests - FormValidatorMixin
def test_validate_returns_content_unchanged_if_no_form_is_set(self):
"""If the form attribute is None then validate(content) should just return the content unmodified."""
class TestDisabledValidations(TestCase):
"""Tests on Validator Mixins with validation disabled by setting form to None"""
def test_disabled_form_validator_returns_content_unchanged(self):
"""If the form attribute is None on FormValidatorMixin then validate(content) should just return the content unmodified."""
class DisabledFormValidator(FormValidatorMixin):
form = None
content = {'qwerty':'uiop'}
self.assertEqual(self.DisabledValidator().validate(content), content)
self.assertEqual(DisabledFormValidator().validate(content), content)
def test_disabled_form_validator_get_bound_form_returns_none(self):
"""If the form attribute is None on FormValidatorMixin then get_bound_form(content) should just return None."""
class DisabledFormValidator(FormValidatorMixin):
form = None
def test_get_bound_form_returns_none_if_no_form_is_set(self):
"""If the form attribute is None then get_bound_form(content) should just return None."""
content = {'qwerty':'uiop'}
self.assertEqual(self.DisabledValidator().get_bound_form(content), None)
self.assertEqual(DisabledFormValidator().get_bound_form(content), None)
def test_validate_returns_content_unchanged_if_validates_and_does_not_need_cleanup(self):
"""If the content is already valid and clean then validate(content) should just return the content unmodified."""
content = {'qwerty':'uiop'}
self.assertEqual(self.MockValidator().validate(content), content)
def test_disabled_model_form_validator_returns_content_unchanged(self):
"""If the form attribute is None on FormValidatorMixin then validate(content) should just return the content unmodified."""
class DisabledModelFormValidator(ModelFormValidatorMixin):
form = None
def test_form_validation_failure_raises_response_exception(self):
"""If form validation fails a ResourceException 400 (Bad Request) should be raised."""
content = {}
self.assertRaises(ResponseException, self.MockValidator().validate, content)
content = {'qwerty':'uiop'}
self.assertEqual(DisabledModelFormValidator().validate(content), content)
def test_validate_does_not_allow_extra_fields(self):
"""If some (otherwise valid) content includes fields that are not in the form then validation should fail.
It might be okay on normal form submission, but for Web APIs we oughta get strict, as it'll help show up
broken clients more easily (eg submitting content with a misnamed field)"""
content = {'qwerty': 'uiop', 'extra': 'extra'}
self.assertRaises(ResponseException, self.MockValidator().validate, content)
def test_disabled_model_form_validator_get_bound_form_returns_none(self):
"""If the form attribute is None on FormValidatorMixin then get_bound_form(content) should just return None."""
class DisabledModelFormValidator(ModelFormValidatorMixin):
form = None
def test_validate_allows_extra_fields_if_explicitly_set(self):
"""If we include an extra_fields paramater on _validate, then allow fields with those names."""
content = {'qwerty': 'uiop', 'extra': 'extra'}
self.MockValidator()._validate(content, extra_fields=('extra',))
content = {'qwerty':'uiop'}
self.assertEqual(DisabledModelFormValidator().get_bound_form(content), None)
def test_validate_checks_for_extra_fields_if_explicitly_set(self):
"""If we include an extra_fields paramater on _validate, then fail unless we have fields with those names."""
content = {'qwerty': 'uiop'}
try:
self.MockValidator()._validate(content, extra_fields=('extra',))
except ResponseException, exc:
self.assertEqual(exc.response.raw_content, {'field-errors': {'extra': ['This field is required.']}})
else:
self.fail('ResourceException was not raised') #pragma: no cover
def test_validate_failed_due_to_no_content_returns_appropriate_message(self):
"""If validation fails due to no content, ensure the response contains a single non-field error"""
content = {}
try:
self.MockValidator().validate(content)
except ResponseException, exc:
self.assertEqual(exc.response.raw_content, {'errors': ['No content was supplied.']})
else:
self.fail('ResourceException was not raised') #pragma: no cover
class TestNonFieldErrors(TestCase):
"""Tests against form validation errors caused by non-field errors. (eg as might be caused by some custom form validation)"""
def test_validate_failed_due_to_field_error_returns_appropriate_message(self):
"""If validation fails due to a field error, ensure the response contains a single field error"""
content = {'qwerty': ''}
try:
self.MockValidator().validate(content)
except ResponseException, exc:
self.assertEqual(exc.response.raw_content, {'field-errors': {'qwerty': ['This field is required.']}})
else:
self.fail('ResourceException was not raised') #pragma: no cover
def test_validate_failed_due_to_invalid_field_returns_appropriate_message(self):
"""If validation fails due to an invalid field, ensure the response contains a single field error"""
content = {'qwerty': 'uiop', 'extra': 'extra'}
try:
self.MockValidator().validate(content)
except ResponseException, exc:
self.assertEqual(exc.response.raw_content, {'field-errors': {'extra': ['This field does not exist.']}})
else:
self.fail('ResourceException was not raised') #pragma: no cover
def test_validate_failed_due_to_multiple_errors_returns_appropriate_message(self):
"""If validation for multiple reasons, ensure the response contains each error"""
content = {'qwerty': '', 'extra': 'extra'}
try:
self.MockValidator().validate(content)
except ResponseException, exc:
self.assertEqual(exc.response.raw_content, {'field-errors': {'qwerty': ['This field is required.'],
'extra': ['This field does not exist.']}})
else:
self.fail('ResourceException was not raised') #pragma: no cover
def test_validate_failed_due_to_non_field_error_returns_appropriate_message(self):
"""If validation for with a non-field error, ensure the response a non-field error"""
"""If validation fails with a non-field error, ensure the response a non-field error"""
class MockForm(forms.Form):
field1 = forms.CharField(required=False)
field2 = forms.CharField(required=False)
@ -148,4 +89,203 @@ class TestValidatorMixins(TestCase):
except ResponseException, exc:
self.assertEqual(exc.response.raw_content, {'errors': [MockForm.ERROR_TEXT]})
else:
self.fail('ResourceException was not raised') #pragma: no cover
self.fail('ResourceException was not raised') #pragma: no cover
class TestFormValidation(TestCase):
"""Tests which check basic form validation.
Also includes the same set of tests with a ModelFormValidator for which the form has been explicitly set.
(ModelFormValidatorMixin should behave as FormValidatorMixin if form is set rather than relying on the default ModelForm)"""
def setUp(self):
class MockForm(forms.Form):
qwerty = forms.CharField(required=True)
class MockFormValidator(FormValidatorMixin):
form = MockForm
class MockModelFormValidator(ModelFormValidatorMixin):
form = MockForm
self.MockFormValidator = MockFormValidator
self.MockModelFormValidator = MockModelFormValidator
def validation_returns_content_unchanged_if_already_valid_and_clean(self, validator):
"""If the content is already valid and clean then validate(content) should just return the content unmodified."""
content = {'qwerty':'uiop'}
self.assertEqual(validator.validate(content), content)
def validation_failure_raises_response_exception(self, validator):
"""If form validation fails a ResourceException 400 (Bad Request) should be raised."""
content = {}
self.assertRaises(ResponseException, validator.validate, content)
def validation_does_not_allow_extra_fields_by_default(self, validator):
"""If some (otherwise valid) content includes fields that are not in the form then validation should fail.
It might be okay on normal form submission, but for Web APIs we oughta get strict, as it'll help show up
broken clients more easily (eg submitting content with a misnamed field)"""
content = {'qwerty': 'uiop', 'extra': 'extra'}
self.assertRaises(ResponseException, validator.validate, content)
def validation_allows_extra_fields_if_explicitly_set(self, validator):
"""If we include an allowed_extra_fields paramater on _validate, then allow fields with those names."""
content = {'qwerty': 'uiop', 'extra': 'extra'}
validator._validate(content, allowed_extra_fields=('extra',))
def validation_does_not_require_extra_fields_if_explicitly_set(self, validator):
"""If we include an allowed_extra_fields paramater on _validate, then do not fail if we do not have fields with those names."""
content = {'qwerty': 'uiop'}
self.assertEqual(validator._validate(content, allowed_extra_fields=('extra',)), content)
def validation_failed_due_to_no_content_returns_appropriate_message(self, validator):
"""If validation fails due to no content, ensure the response contains a single non-field error"""
content = {}
try:
validator.validate(content)
except ResponseException, exc:
self.assertEqual(exc.response.raw_content, {'errors': ['No content was supplied.']})
else:
self.fail('ResourceException was not raised') #pragma: no cover
def validation_failed_due_to_field_error_returns_appropriate_message(self, validator):
"""If validation fails due to a field error, ensure the response contains a single field error"""
content = {'qwerty': ''}
try:
validator.validate(content)
except ResponseException, exc:
self.assertEqual(exc.response.raw_content, {'field-errors': {'qwerty': ['This field is required.']}})
else:
self.fail('ResourceException was not raised') #pragma: no cover
def validation_failed_due_to_invalid_field_returns_appropriate_message(self, validator):
"""If validation fails due to an invalid field, ensure the response contains a single field error"""
content = {'qwerty': 'uiop', 'extra': 'extra'}
try:
validator.validate(content)
except ResponseException, exc:
self.assertEqual(exc.response.raw_content, {'field-errors': {'extra': ['This field does not exist.']}})
else:
self.fail('ResourceException was not raised') #pragma: no cover
def validation_failed_due_to_multiple_errors_returns_appropriate_message(self, validator):
"""If validation for multiple reasons, ensure the response contains each error"""
content = {'qwerty': '', 'extra': 'extra'}
try:
validator.validate(content)
except ResponseException, exc:
self.assertEqual(exc.response.raw_content, {'field-errors': {'qwerty': ['This field is required.'],
'extra': ['This field does not exist.']}})
else:
self.fail('ResourceException was not raised') #pragma: no cover
# Tests on FormValidtionMixin
def test_form_validation_returns_content_unchanged_if_already_valid_and_clean(self):
self.validation_returns_content_unchanged_if_already_valid_and_clean(self.MockFormValidator())
def test_form_validation_failure_raises_response_exception(self):
self.validation_failure_raises_response_exception(self.MockFormValidator())
def test_validation_does_not_allow_extra_fields_by_default(self):
self.validation_does_not_allow_extra_fields_by_default(self.MockFormValidator())
def test_validation_allows_extra_fields_if_explicitly_set(self):
self.validation_allows_extra_fields_if_explicitly_set(self.MockFormValidator())
def test_validation_does_not_require_extra_fields_if_explicitly_set(self):
self.validation_does_not_require_extra_fields_if_explicitly_set(self.MockFormValidator())
def test_validation_failed_due_to_no_content_returns_appropriate_message(self):
self.validation_failed_due_to_no_content_returns_appropriate_message(self.MockFormValidator())
def test_validation_failed_due_to_field_error_returns_appropriate_message(self):
self.validation_failed_due_to_field_error_returns_appropriate_message(self.MockFormValidator())
def test_validation_failed_due_to_invalid_field_returns_appropriate_message(self):
self.validation_failed_due_to_invalid_field_returns_appropriate_message(self.MockFormValidator())
def test_validation_failed_due_to_multiple_errors_returns_appropriate_message(self):
self.validation_failed_due_to_multiple_errors_returns_appropriate_message(self.MockFormValidator())
# Same tests on ModelFormValidtionMixin
def test_modelform_validation_returns_content_unchanged_if_already_valid_and_clean(self):
self.validation_returns_content_unchanged_if_already_valid_and_clean(self.MockModelFormValidator())
def test_modelform_validation_failure_raises_response_exception(self):
self.validation_failure_raises_response_exception(self.MockModelFormValidator())
def test_modelform_validation_does_not_allow_extra_fields_by_default(self):
self.validation_does_not_allow_extra_fields_by_default(self.MockModelFormValidator())
def test_modelform_validation_allows_extra_fields_if_explicitly_set(self):
self.validation_allows_extra_fields_if_explicitly_set(self.MockModelFormValidator())
def test_modelform_validation_does_not_require_extra_fields_if_explicitly_set(self):
self.validation_does_not_require_extra_fields_if_explicitly_set(self.MockModelFormValidator())
def test_modelform_validation_failed_due_to_no_content_returns_appropriate_message(self):
self.validation_failed_due_to_no_content_returns_appropriate_message(self.MockModelFormValidator())
def test_modelform_validation_failed_due_to_field_error_returns_appropriate_message(self):
self.validation_failed_due_to_field_error_returns_appropriate_message(self.MockModelFormValidator())
def test_modelform_validation_failed_due_to_invalid_field_returns_appropriate_message(self):
self.validation_failed_due_to_invalid_field_returns_appropriate_message(self.MockModelFormValidator())
def test_modelform_validation_failed_due_to_multiple_errors_returns_appropriate_message(self):
self.validation_failed_due_to_multiple_errors_returns_appropriate_message(self.MockModelFormValidator())
class TestModelFormValidator(TestCase):
"""Tests specific to ModelFormValidatorMixin"""
def setUp(self):
"""Create a validator for a model with two fields and a property."""
class MockModel(models.Model):
qwerty = models.CharField(max_length=256)
uiop = models.CharField(max_length=256, blank=True)
@property
def readonly(self):
return 'read only'
class MockValidator(ModelFormValidatorMixin):
model = MockModel
self.MockValidator = MockValidator
def test_property_fields_are_allowed_on_model_forms(self):
"""Validation on ModelForms may include property fields that exist on the Model to be included in the input."""
content = {'qwerty':'example', 'uiop': 'example', 'readonly': 'read only'}
self.assertEqual(self.MockValidator().validate(content), content)
def test_property_fields_are_not_required_on_model_forms(self):
"""Validation on ModelForms does not require property fields that exist on the Model to be included in the input."""
content = {'qwerty':'example', 'uiop': 'example'}
self.assertEqual(self.MockValidator().validate(content), content)
def test_extra_fields_not_allowed_on_model_forms(self):
"""If some (otherwise valid) content includes fields that are not in the form then validation should fail.
It might be okay on normal form submission, but for Web APIs we oughta get strict, as it'll help show up
broken clients more easily (eg submitting content with a misnamed field)"""
content = {'qwerty': 'example', 'uiop':'example', 'readonly': 'read only', 'extra': 'extra'}
self.assertRaises(ResponseException, self.MockValidator().validate, content)
def test_validate_requires_fields_on_model_forms(self):
"""If some (otherwise valid) content includes fields that are not in the form then validation should fail.
It might be okay on normal form submission, but for Web APIs we oughta get strict, as it'll help show up
broken clients more easily (eg submitting content with a misnamed field)"""
content = {'readonly': 'read only'}
self.assertRaises(ResponseException, self.MockValidator().validate, content)
def test_validate_does_not_require_blankable_fields_on_model_forms(self):
"""Test standard ModelForm validation behaviour - fields with blank=True are not required."""
content = {'qwerty':'example', 'readonly': 'read only'}
self.MockValidator().validate(content)
def test_model_form_validator_uses_model_forms(self):
self.assertTrue(isinstance(self.MockValidator().get_bound_form(), forms.ModelForm))

View File

@ -0,0 +1,43 @@
from django.conf.urls.defaults import patterns, url
from django.test import TestCase
from django.test import Client
urlpatterns = patterns('djangorestframework.views',
url(r'^robots.txt$', 'deny_robots'),
url(r'^favicon.ico$', 'favicon'),
url(r'^accounts/login$', 'api_login'),
url(r'^accounts/logout$', 'api_logout'),
)
class ViewTests(TestCase):
"""Test the extra views djangorestframework provides"""
urls = 'djangorestframework.tests.views'
def test_robots_view(self):
"""Ensure the robots view exists"""
response = self.client.get('/robots.txt')
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'text/plain')
def test_favicon_view(self):
"""Ensure the favicon view exists"""
response = self.client.get('/favicon.ico')
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'image/vnd.microsoft.icon')
def test_login_view(self):
"""Ensure the login view exists"""
response = self.client.get('/accounts/login')
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'].split(';')[0], 'text/html')
def test_logout_view(self):
"""Ensure the logout view exists"""
response = self.client.get('/accounts/logout')
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'].split(';')[0], 'text/html')
# TODO: Add login/logout behaviour tests

View File

@ -3,12 +3,18 @@ import xml.etree.ElementTree as ET
from django.utils.encoding import smart_unicode
from django.utils.xmlutils import SimplerXMLGenerator
from django.core.urlresolvers import resolve
from django.conf import settings
try:
import cStringIO as StringIO
except ImportError:
import StringIO
#def admin_media_prefix(request):
# """Adds the ADMIN_MEDIA_PREFIX to the request context."""
# return {'ADMIN_MEDIA_PREFIX': settings.ADMIN_MEDIA_PREFIX}
def as_tuple(obj):
"""Given obj return a tuple"""
if obj is None:

View File

@ -22,6 +22,7 @@ class FormValidatorMixin(ValidatorMixin):
"""The form class that should be used for validation, or None to turn off form validation."""
form = None
bound_form_instance = None
def validate(self, content):
"""Given some content as input return some cleaned, validated content.
@ -34,29 +35,39 @@ class FormValidatorMixin(ValidatorMixin):
If the 'field-errors' key exists it is a dict of {field name as string: list of errors as strings}."""
return self._validate(content)
def _validate(self, content, extra_fields=()):
def _validate(self, content, allowed_extra_fields=()):
"""Wrapped by validate to hide the extra_fields option that the ModelValidatorMixin uses.
extra_fields is a list of fields which are not defined by the form, but which we still
expect to see on the input."""
if self.form is None:
return content
bound_form = self.get_bound_form(content)
# In addition to regular validation we also ensure no additional fields are being passed in...
unknown_fields = set(content.keys()) - set(self.form().fields.keys()) - set(extra_fields)
if bound_form is None:
return content
self.bound_form_instance = bound_form
# And that any extra fields we have specified are all present.
missing_extra_fields = set(extra_fields) - set(content.keys())
seen_fields_set = set(content.keys())
form_fields_set = set(bound_form.fields.keys())
allowed_extra_fields_set = set(allowed_extra_fields)
# In addition to regular validation we also ensure no additional fields are being passed in...
unknown_fields = seen_fields_set - (form_fields_set | allowed_extra_fields_set)
# Check using both regular validation, and our stricter no additional fields rule
if bound_form.is_valid() and not unknown_fields and not missing_extra_fields:
return bound_form.cleaned_data
if bound_form.is_valid() and not unknown_fields:
# Validation succeeded...
cleaned_data = bound_form.cleaned_data
# Add in any extra fields to the cleaned content...
for key in (allowed_extra_fields_set & seen_fields_set) - set(cleaned_data.keys()):
cleaned_data[key] = content[key]
return cleaned_data
# Validation failed...
detail = {}
if not bound_form.errors and not unknown_fields and not missing_extra_fields:
if not bound_form.errors and not unknown_fields:
detail = {u'errors': [u'No content was supplied.']}
else:
@ -70,10 +81,6 @@ class FormValidatorMixin(ValidatorMixin):
# Add any unknown field errors
for key in unknown_fields:
field_errors[key] = [u'This field does not exist.']
# Add any missing fields that we required by the extra fields argument
for key in missing_extra_fields:
field_errors[key] = [u'This field is required.']
if field_errors:
detail[u'field-errors'] = field_errors
@ -105,8 +112,14 @@ class ModelFormValidatorMixin(FormValidatorMixin):
model = None
"""The list of fields we expect to receive as input. Fields in this list will may be received with
raising non-existent field errors, even if they do not exist as fields on the ModelForm."""
raising non-existent field errors, even if they do not exist as fields on the ModelForm.
Setting the fields class attribute causes the exclude_fields class attribute to be disregarded."""
fields = None
"""The list of fields to exclude from the Model. This is only used if the fields class attribute is not set."""
exclude_fields = ('id', 'pk')
# TODO: test the different validation here to allow for get get_absolute_url to be supplied on input and not bork out
# TODO: be really strict on fields - check they match in the handler methods. (this isn't a validator thing tho.)
@ -122,8 +135,7 @@ class ModelFormValidatorMixin(FormValidatorMixin):
On failure the ResponseException content is a dict which may contain 'errors' and 'field-errors' keys.
If the 'errors' key exists it is a list of strings of non-field errors.
If the 'field-errors' key exists it is a dict of {field name as string: list of errors as strings}."""
extra_fields = set(as_tuple(self.fields)) - set(self.get_bound_form().fields)
return self._validate(content, extra_fields)
return self._validate(content, allowed_extra_fields=self._property_fields_set)
def get_bound_form(self, content=None):
@ -131,23 +143,50 @@ class ModelFormValidatorMixin(FormValidatorMixin):
If the form class attribute has been explicitly set then use that class to create a Form,
otherwise if model is set use that class to create a ModelForm, otherwise return None."""
if self.form:
# Use explict Form
return super(ModelFormValidatorMixin, self).get_bound_form(content)
elif self.model:
# Fall back to ModelForm which we create on the fly
class ModelForm(forms.ModelForm):
class OnTheFlyModelForm(forms.ModelForm):
class Meta:
model = self.model
fields = tuple(set.intersection(self.model._meta.fields, self.fields))
#fields = tuple(self._model_fields_set)
# Instantiate the ModelForm as appropriate
if content and isinstance(content, models.Model):
return ModelForm(instance=content)
return OnTheFlyModelForm(instance=content)
elif content:
return ModelForm(content)
return ModelForm()
return OnTheFlyModelForm(content)
return OnTheFlyModelForm()
# Both form and model not set? Okay bruv, whatevs...
return None
return None
@property
def _model_fields_set(self):
"""Return a set containing the names of validated fields on the model."""
model_fields = set(field.name for field in self.model._meta.fields)
if self.fields:
return model_fields & set(as_tuple(self.fields))
return model_fields - set(as_tuple(self.exclude_fields))
@property
def _property_fields_set(self):
"""Returns a set containing the names of validated properties on the model."""
property_fields = set(attr for attr in dir(self.model) if
isinstance(getattr(self.model, attr, None), property)
and not attr.startswith('_'))
if self.fields:
return property_fields & set(as_tuple(self.fields))
return property_fields - set(as_tuple(self.exclude_fields))

View File

@ -0,0 +1,66 @@
from django.contrib.auth.views import *
#from django.contrib.sites.models import get_current_site
from django.conf import settings
from django.http import HttpResponse
import base64
def deny_robots(request):
return HttpResponse('User-agent: *\nDisallow: /', mimetype='text/plain')
def favicon(request):
data = 'AAABAAEAEREAAAEAIADwBAAAFgAAACgAAAARAAAAIgAAAAEAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADLy8tLy8vL3svLy1QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAy8vLBsvLywkAAAAATkZFS1xUVPqhn57/y8vL0gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJmVlQ/GxcXiy8vL88vLy4FdVlXzTkZF/2RdXP/Ly8vty8vLtMvLy5DLy8vty8vLxgAAAAAAAAAAAAAAAAAAAABORkUJTkZF4lNMS/+Lh4f/cWtq/05GRf9ORkX/Vk9O/3JtbP+Ef3//Vk9O/2ljYv/Ly8v5y8vLCQAAAAAAAAAAAAAAAE5GRQlORkX2TkZF/05GRf9ORkX/TkZF/05GRf9ORkX/TkZF/05GRf9ORkX/UElI/8PDw5cAAAAAAAAAAAAAAAAAAAAAAAAAAE5GRZZORkX/TkZF/05GRf9ORkX/TkZF/05GRf9ORkX/TkZF/05GRf+Cfn3/y8vLvQAAAAAAAAAAAAAAAAAAAADLy8tIaWNi805GRf9ORkX/YVpZ/396eV7Ly8t7qaen9lZOTu5ORkX/TkZF/25oZ//Ly8v/y8vLycvLy0gAAAAATkZFSGNcXPpORkX/TkZF/05GRf+ysLDzTkZFe1NLSv6Oior/raur805GRf9ORkX/TkZF/2hiYf+npaX/y8vL5wAAAABORkXnTkZF/05GRf9ORkX/VU1M/8vLy/9PR0b1TkZF/1VNTP/Ly8uQT0dG+E5GRf9ORkX/TkZF/1hRUP3Ly8tmAAAAAE5GRWBORkXkTkZF/05GRf9ORkX/t7a2/355eOpORkX/TkZFkISAf1BORkX/TkZF/05GRf9XT075TkZFZgAAAAAAAAAAAAAAAAAAAABORkXDTkZF/05GRf9lX17/ubi4/8vLy/+2tbT/Yltb/05GRf9ORkX/a2Vk/8vLy5MAAAAAAAAAAAAAAAAAAAAAAAAAAFNLSqNORkX/TkZF/05GRf9ORkX/TkZF/05GRf9ORkX/TkZF/05GRf+Cfn3/y8vL+cvLyw8AAAAAAAAAAAAAAABORkUSTkZF+U5GRf9ORkX/TkZF/05GRf9ORkX/TkZF/05GRf9ORkX/TkZF/1BJSP/CwsLmy8vLDwAAAAAAAAAAAAAAAE5GRRJORkXtTkZF9FFJSJ1ORkXJTkZF/05GRf9ORkX/ZF5d9k5GRZ9ORkXtTkZF5HFsaxUAAAAAAAAAAAAAAAAAAAAAAAAAAE5GRQxORkUJAAAAAAAAAABORkXhTkZF/2JbWv7Ly8tgAAAAAAAAAABORkUGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE5GRWBORkX2TkZFYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//+AAP9/gAD+P4AA4AOAAMADgADAA4AAwAOAAMMBgACCAIAAAAGAAIBDgADAA4AAwAOAAMADgADAB4AA/H+AAP7/gAA='
return HttpResponse(base64.b64decode(data), mimetype='image/vnd.microsoft.icon')
# BLERGH
# Replicate django.contrib.auth.views.login simply so we don't have get users to update TEMPLATE_CONTEXT_PROCESSORS
# to add ADMIN_MEDIA_PREFIX to the RequestContext. I don't like this but really really want users to not have to
# be making settings changes in order to accomodate django-rest-framework
@csrf_protect
@never_cache
def api_login(request, template_name='api_login.html',
redirect_field_name=REDIRECT_FIELD_NAME,
authentication_form=AuthenticationForm):
"""Displays the login form and handles the login action."""
redirect_to = request.REQUEST.get(redirect_field_name, '')
if request.method == "POST":
form = authentication_form(data=request.POST)
if form.is_valid():
# Light security check -- make sure redirect_to isn't garbage.
if not redirect_to or ' ' in redirect_to:
redirect_to = settings.LOGIN_REDIRECT_URL
# Heavier security check -- redirects to http://example.com should
# not be allowed, but things like /view/?param=http://example.com
# should be allowed. This regex checks if there is a '//' *before* a
# question mark.
elif '//' in redirect_to and re.match(r'[^\?]*//', redirect_to):
redirect_to = settings.LOGIN_REDIRECT_URL
# Okay, security checks complete. Log the user in.
auth_login(request, form.get_user())
if request.session.test_cookie_worked():
request.session.delete_test_cookie()
return HttpResponseRedirect(redirect_to)
else:
form = authentication_form(request)
request.session.set_test_cookie()
#current_site = get_current_site(request)
return render_to_response(template_name, {
'form': form,
redirect_field_name: redirect_to,
#'site': current_site,
#'site_name': current_site.name,
'ADMIN_MEDIA_PREFIX': settings.ADMIN_MEDIA_PREFIX,
}, context_instance=RequestContext(request))
def api_logout(request, next_page=None, template_name='api_login.html', redirect_field_name=REDIRECT_FIELD_NAME):
return logout(request, next_page, template_name, redirect_field_name)

View File

@ -24,13 +24,13 @@ class BlogPost(models.Model):
@models.permalink
def get_absolute_url(self):
return ('blogpost.views.BlogPostInstance', (), {'key': self.key})
return ('blog-post', (), {'key': self.key})
@property
@models.permalink
def comments_url(self):
"""Link to a resource which lists all comments for this blog post."""
return ('blogpost.views.CommentRoot', (), {'blogpost_id': self.key})
return ('comments', (), {'blogpost_id': self.key})
def __unicode__(self):
return self.title
@ -52,11 +52,11 @@ class Comment(models.Model):
@models.permalink
def get_absolute_url(self):
return ('blogpost.views.CommentInstance', (), {'blogpost': self.blogpost.key, 'id': self.id})
return ('comment', (), {'blogpost': self.blogpost.key, 'id': self.id})
@property
@models.permalink
def blogpost_url(self):
"""Link to the blog post resource which this comment corresponds to."""
return ('blogpost.views.BlogPostInstance', (), {'key': self.blogpost.key})
return ('blog-post', (), {'key': self.blogpost.key})

View File

@ -1,8 +1,9 @@
from django.conf.urls.defaults import patterns
from django.conf.urls.defaults import patterns, url
from blogpost.views import BlogPosts, BlogPostInstance, Comments, CommentInstance
urlpatterns = patterns('blogpost.views',
(r'^$', 'BlogPostRoot'),
(r'^(?P<key>[^/]+)/$', 'BlogPostInstance'),
(r'^(?P<blogpost_id>[^/]+)/comments/$', 'CommentRoot'),
(r'^(?P<blogpost>[^/]+)/comments/(?P<id>[^/]+)/$', 'CommentInstance'),
urlpatterns = patterns('',
url(r'^$', BlogPosts.as_view(), name='blog-posts'),
url(r'^(?P<key>[^/]+)/$', BlogPostInstance.as_view(), name='blog-post'),
url(r'^(?P<blogpost_id>[^/]+)/comments/$', Comments.as_view(), name='comments'),
url(r'^(?P<blogpost>[^/]+)/comments/(?P<id>[^/]+)/$', CommentInstance.as_view(), name='comment'),
)

View File

@ -1,33 +1,33 @@
from djangorestframework.response import Response, status
from djangorestframework.resource import Resource
from djangorestframework.modelresource import ModelResource, RootModelResource
from blogpost.models import BlogPost, Comment
from blogpost import models
BLOG_POST_FIELDS = ('created', 'title', 'slug', 'content', 'absolute_url', 'comment_url', 'comments_url')
COMMENT_FIELDS = ('username', 'comment', 'created', 'rating', 'absolute_url', 'blogpost_url')
class BlogPostRoot(RootModelResource):
class BlogPosts(RootModelResource):
"""A resource with which lists all existing blog posts and creates new blog posts."""
allowed_methods = ('GET', 'POST',)
model = BlogPost
anon_allowed_methods = allowed_methods = ('GET', 'POST',)
model = models.BlogPost
fields = BLOG_POST_FIELDS
class BlogPostInstance(ModelResource):
"""A resource which represents a single blog post."""
allowed_methods = ('GET', 'PUT', 'DELETE')
model = BlogPost
anon_allowed_methods = allowed_methods = ('GET', 'PUT', 'DELETE')
model = models.BlogPost
fields = BLOG_POST_FIELDS
class CommentRoot(RootModelResource):
class Comments(RootModelResource):
"""A resource which lists all existing comments for a given blog post, and creates new blog comments for a given blog post."""
allowed_methods = ('GET', 'POST',)
model = Comment
anon_allowed_methods = allowed_methods = ('GET', 'POST',)
model = models.Comment
fields = COMMENT_FIELDS
class CommentInstance(ModelResource):
"""A resource which represents a single comment."""
allowed_methods = ('GET', 'PUT', 'DELETE')
model = Comment
anon_allowed_methods = allowed_methods = ('GET', 'PUT', 'DELETE')
model = models.Comment
fields = COMMENT_FIELDS

View File

23
examples/mixin/urls.py Normal file
View File

@ -0,0 +1,23 @@
from djangorestframework.compat import View # Use Django 1.3's django.views.generic.View, or fall back to a clone of that if Django < 1.3
from djangorestframework.emitters import EmitterMixin, DEFAULT_EMITTERS
from djangorestframework.response import Response
from django.conf.urls.defaults import patterns, url
from django.core.urlresolvers import reverse
class ExampleView(EmitterMixin, View):
"""An example view using Django 1.3's class based views.
Uses djangorestframework's EmitterMixin to provide support for multiple output formats."""
emitters = DEFAULT_EMITTERS
def get(self, request):
response = Response(200, {'description': 'Some example content',
'url': reverse('mixin-view')})
return self.emit(response)
urlpatterns = patterns('',
url(r'^$', ExampleView.as_view(), name='mixin-view'),
)

View File

@ -19,5 +19,5 @@ class MyModel(models.Model):
@models.permalink
def get_absolute_url(self):
return ('modelresourceexample.views.MyModelResource', (self.pk,))
return ('my-model-resource', (self.pk,))

View File

@ -1,6 +1,7 @@
from django.conf.urls.defaults import patterns, url
from modelresourceexample.views import MyModelRootResource, MyModelResource
urlpatterns = patterns('modelresourceexample.views',
url(r'^$', 'MyModelRootResource'),
url(r'^([0-9]+)/$', 'MyModelResource'),
url(r'^$', MyModelRootResource.as_view(), name='my-model-root-resource'),
url(r'^([0-9]+)/$', MyModelResource.as_view(), name='my-model-resource'),
)

View File

@ -1,6 +1,7 @@
from django.conf.urls.defaults import patterns
from django.conf.urls.defaults import patterns, url
from objectstore.views import ObjectStoreRoot, StoredObject
urlpatterns = patterns('objectstore.views',
(r'^$', 'ObjectStoreRoot'),
(r'^(?P<key>[A-Za-z0-9_-]{1,64})/$', 'StoredObject'),
url(r'^$', ObjectStoreRoot.as_view(), name='object-store-root'),
url(r'^(?P<key>[A-Za-z0-9_-]{1,64})/$', StoredObject.as_view(), name='stored-object'),
)

View File

@ -1,4 +1,5 @@
from django.conf import settings
from django.core.urlresolvers import reverse
from djangorestframework.resource import Resource
from djangorestframework.response import Response, status
@ -29,7 +30,7 @@ class ObjectStoreRoot(Resource):
def get(self, request, auth):
"""Return a list of all the stored object URLs."""
keys = sorted(os.listdir(OBJECT_STORE_DIR))
return [self.reverse(StoredObject, key=key) for key in keys]
return [reverse('stored-object', kwargs={'key':key}) for key in keys]
def post(self, request, auth, content):
"""Create a new stored object, with a unique key."""
@ -37,9 +38,9 @@ class ObjectStoreRoot(Resource):
pathname = os.path.join(OBJECT_STORE_DIR, key)
pickle.dump(content, open(pathname, 'wb'))
remove_oldest_files(OBJECT_STORE_DIR, MAX_FILES)
return Response(status.HTTP_201_CREATED, content, {'Location': self.reverse(StoredObject, key=key)})
return Response(status.HTTP_201_CREATED, content, {'Location': reverse('stored-object', kwargs={'key':key})})
class StoredObject(Resource):
"""Represents a stored object.
The object may be any picklable content."""

View File

@ -1,6 +1,7 @@
from django.conf.urls.defaults import patterns
from django.conf.urls.defaults import patterns, url
from pygments_api.views import PygmentsRoot, PygmentsInstance
urlpatterns = patterns('pygments_api.views',
(r'^$', 'PygmentsRoot'),
(r'^([a-zA-Z0-9-]+)/$', 'PygmentsInstance'),
urlpatterns = patterns('',
url(r'^$', PygmentsRoot.as_view(), name='pygments-root'),
url(r'^([a-zA-Z0-9-]+)/$', PygmentsInstance.as_view(), name='pygments-instance'),
)

View File

@ -1,4 +1,6 @@
from __future__ import with_statement # for python 2.5
from django.conf import settings
from django.core.urlresolvers import reverse
from djangorestframework.resource import Resource
from djangorestframework.response import Response, status
@ -41,7 +43,7 @@ class PygmentsRoot(Resource):
def get(self, request, auth):
"""Return a list of all currently existing snippets."""
unique_ids = sorted(os.listdir(HIGHLIGHTED_CODE_DIR))
return [self.reverse(PygmentsInstance, unique_id) for unique_id in unique_ids]
return [reverse('pygments-instance', args=[unique_id]) for unique_id in unique_ids]
def post(self, request, auth, content):
"""Create a new highlighed snippet and return it's location.
@ -59,7 +61,7 @@ class PygmentsRoot(Resource):
remove_oldest_files(HIGHLIGHTED_CODE_DIR, MAX_FILES)
return Response(status.HTTP_201_CREATED, headers={'Location': self.reverse(PygmentsInstance, unique_id)})
return Response(status.HTTP_201_CREATED, headers={'Location': reverse('pygments-instance', args=[unique_id])})
class PygmentsInstance(Resource):

View File

@ -4,3 +4,5 @@ Django==1.2.4
wsgiref==0.1.2
Pygments==1.4
httplib2==0.6.0
Markdown==2.0.3

View File

@ -1,6 +1,7 @@
from django.conf.urls.defaults import patterns, url
from resourceexample.views import ExampleResource, AnotherExampleResource
urlpatterns = patterns('resourceexample.views',
url(r'^$', 'ExampleResource'),
url(r'^(?P<num>[0-9]+)/$', 'AnotherExampleResource'),
urlpatterns = patterns('',
url(r'^$', ExampleResource.as_view(), name='example-resource'),
url(r'^(?P<num>[0-9]+)/$', AnotherExampleResource.as_view(), name='another-example-resource'),
)

View File

@ -1,13 +1,14 @@
from django.core.urlresolvers import reverse
from djangorestframework.resource import Resource
from djangorestframework.response import Response, status
from resourceexample.forms import MyForm
class ExampleResource(Resource):
"""A basic read only resource that points to 3 other resources."""
"""A basic read-only resource that points to 3 other resources."""
allowed_methods = anon_allowed_methods = ('GET',)
def get(self, request, auth):
return {"Some other resources": [self.reverse(AnotherExampleResource, num=num) for num in range(3)]}
return {"Some other resources": [reverse('another-example-resource', kwargs={'num':num}) for num in range(3)]}
class AnotherExampleResource(Resource):
"""A basic GET-able/POST-able resource."""

View File

35
examples/sandbox/views.py Normal file
View File

@ -0,0 +1,35 @@
"""The root view for the examples provided with Django REST framework"""
from django.core.urlresolvers import reverse
from djangorestframework.resource import Resource
class Sandbox(Resource):
"""This is the sandbox for the examples provided with [Django REST framework](http://django-rest-framework.org).
These examples are provided to help you get a better idea of the some of the features of RESTful APIs created using the framework.
All the example APIs allow anonymous access, and can be navigated either through the browser or from the command line...
bash: curl -X GET http://api.django-rest-framework.org/ # (Use default emitter)
bash: curl -X GET http://api.django-rest-framework.org/ -H 'Accept: text/plain' # (Use plaintext documentation emitter)
The examples provided:
1. A basic example using the [Resource](http://django-rest-framework.org/library/resource.html) class.
2. A basic example using the [ModelResource](http://django-rest-framework.org/library/modelresource.html) class.
3. An basic example using Django 1.3's [class based views](http://docs.djangoproject.com/en/dev/topics/class-based-views/) and djangorestframework's [EmitterMixin](http://django-rest-framework.org/library/emitters.html).
4. A generic object store API.
5. A code highlighting API.
6. A blog posts and comments API.
Please feel free to browse, create, edit and delete the resources in these examples."""
allowed_methods = anon_allowed_methods = ('GET',)
def get(self, request, auth):
return [{'name': 'Simple Resource example', 'url': reverse('example-resource')},
{'name': 'Simple ModelResource example', 'url': reverse('my-model-root-resource')},
{'name': 'Simple Mixin-only example', 'url': reverse('mixin-view')},
{'name': 'Object store API', 'url': reverse('object-store-root')},
{'name': 'Code highlighting API', 'url': reverse('pygments-root')},
{'name': 'Blog posts API', 'url': reverse('blog-posts')}]

View File

@ -1,7 +1,4 @@
# Django settings for src project.
import os
BASE_DIR = os.path.dirname(__file__)
# Settings for djangorestframework examples project
DEBUG = True
TEMPLATE_DEBUG = DEBUG
@ -48,17 +45,23 @@ USE_L10N = True
# Absolute filesystem path to the directory that will hold user-uploaded files.
# Example: "/home/media/media.lawrence.com/"
# NOTE: Some of the djangorestframework examples use MEDIA_ROOT to store content.
MEDIA_ROOT = 'media/'
# URL that handles the media served from MEDIA_ROOT. Make sure to use a
# trailing slash if there is a path component (optional in other cases).
# Examples: "http://media.lawrence.com", "http://example.com/media/"
# NOTE: None of the djangorestframework examples serve media content via MEDIA_URL.
MEDIA_URL = ''
# URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a
# trailing slash.
# Examples: "http://foo.com/media/", "/media/".
ADMIN_MEDIA_PREFIX = '/media/'
# NOTE: djangorestframework does not require the admin app to be installed,
# but it does require the admin media be served. Django's test server will do
# this for you automatically, but in production you'll want to make sure you
# serve the admin media from somewhere.
ADMIN_MEDIA_PREFIX = '/admin-media/'
# Make this unique, and don't share it with anybody.
SECRET_KEY = 't&9mru2_k$t8e2-9uq-wu2a1)9v*us&j3i#lsqkt(lbx*vh1cu'
@ -84,16 +87,16 @@ TEMPLATE_DIRS = (
# Put strings here, like "/home/html/django_templates" or "C:/www/django/templates".
# Always use forward slashes, even on Windows.
# Don't forget to use absolute paths, not relative paths.
os.path.join(BASE_DIR, 'templates')
)
INSTALLED_APPS = (
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.sites',
'django.contrib.messages',
#'django.contrib.admin',
'djangorestframework',
'resourceexample',

View File

@ -1,7 +0,0 @@
<html>
<head>
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>

View File

@ -1,26 +0,0 @@
{% extends "base.html" %}
{% block content %}
{% if form.errors %}
<p>Your username and password didn't match. Please try again.</p>
{% endif %}
<form method="post" action="{% url django.contrib.auth.views.login %}">
{% csrf_token %}
<table>
<tr>
<td>{{ form.username.label_tag }}</td>
<td>{{ form.username }}</td>
</tr>
<tr>
<td>{{ form.password.label_tag }}</td>
<td>{{ form.password }}</td>
</tr>
</table>
<input type="submit" value="login" />
<input type="hidden" name="next" value="{{ next }}" />
</form>
{% endblock %}

View File

@ -1,37 +1,19 @@
from django.conf.urls.defaults import patterns, include
#from django.contrib import admin
from djangorestframework.resource import Resource
from django.conf.urls.defaults import patterns, include, url
from sandbox.views import Sandbox
#admin.autodiscover()
urlpatterns = patterns('djangorestframework.views',
(r'robots.txt', 'deny_robots'),
(r'favicon.ico', 'favicon'),
class RootResource(Resource):
"""This is the sandbox for the examples provided with django-rest-framework.
(r'^$', Sandbox.as_view()),
These examples are here to help you get a better idea of the some of the
features of django-rest-framework API, such as automatic form and model validation,
support for multiple input and output media types, etc...
Please feel free to browse, create, edit and delete the resources here, either
in the browser, from the command line, or programmatically."""
allowed_methods = anon_allowed_methods = ('GET',)
def get(self, request, auth):
return {'Simple Resource example': self.reverse('resourceexample.views.ExampleResource'),
'Simple ModelResource example': self.reverse('modelresourceexample.views.MyModelRootResource'),
'Object store API (Resource)': self.reverse('objectstore.views.ObjectStoreRoot'),
'A pygments pastebin API (Resource + forms)': self.reverse('pygments_api.views.PygmentsRoot'),
'Blog posts API (ModelResource)': self.reverse('blogpost.views.BlogPostRoot'),}
urlpatterns = patterns('',
(r'^$', RootResource),
(r'^model-resource-example/', include('modelresourceexample.urls')),
(r'^resource-example/', include('resourceexample.urls')),
(r'^model-resource-example/', include('modelresourceexample.urls')),
(r'^mixin/', include('mixin.urls')),
(r'^object-store/', include('objectstore.urls')),
(r'^pygments/', include('pygments_api.urls')),
(r'^blog-post/', include('blogpost.urls')),
(r'^accounts/login/$', 'django.contrib.auth.views.login'),
(r'^accounts/logout/$', 'django.contrib.auth.views.logout'),
#(r'^admin/doc/', include('django.contrib.admindocs.urls')),
#(r'^admin/', include(admin.site.urls)),
(r'^accounts/login/$', 'api_login'),
(r'^accounts/logout/$', 'api_logout'),
)

View File

@ -1,5 +1,5 @@
# Django and pip are required if installing into a virtualenv environment...
# We need Django. Duh.
Django==1.2.4
distribute==0.6.14
wsgiref==0.1.2

0
testproject/__init__.py Normal file
View File

11
testproject/manage.py Executable file
View File

@ -0,0 +1,11 @@
#!/usr/bin/env python
from django.core.management import execute_manager
try:
import settings # Assumed to be in the same directory.
except ImportError:
import sys
sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__)
sys.exit(1)
if __name__ == "__main__":
execute_manager(settings)

97
testproject/settings.py Normal file
View File

@ -0,0 +1,97 @@
# Django settings for testproject project.
DEBUG = True
TEMPLATE_DEBUG = DEBUG
ADMINS = (
# ('Your Name', 'your_email@domain.com'),
)
MANAGERS = ADMINS
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'.
'NAME': 'sqlite.db', # Or path to database file if using sqlite3.
'USER': '', # Not used with sqlite3.
'PASSWORD': '', # Not used with sqlite3.
'HOST': '', # Set to empty string for localhost. Not used with sqlite3.
'PORT': '', # Set to empty string for default. Not used with sqlite3.
}
}
# Local time zone for this installation. Choices can be found here:
# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
# although not all choices may be available on all operating systems.
# On Unix systems, a value of None will cause Django to use the same
# timezone as the operating system.
# If running in a Windows environment this must be set to the same as your
# system time zone.
TIME_ZONE = 'Europe/London'
# Language code for this installation. All choices can be found here:
# http://www.i18nguy.com/unicode/language-identifiers.html
LANGUAGE_CODE = 'en-uk'
SITE_ID = 1
# If you set this to False, Django will make some optimizations so as not
# to load the internationalization machinery.
USE_I18N = True
# If you set this to False, Django will not format dates, numbers and
# calendars according to the current locale
USE_L10N = True
# Absolute filesystem path to the directory that will hold user-uploaded files.
# Example: "/home/media/media.lawrence.com/"
MEDIA_ROOT = ''
# URL that handles the media served from MEDIA_ROOT. Make sure to use a
# trailing slash if there is a path component (optional in other cases).
# Examples: "http://media.lawrence.com", "http://example.com/media/"
MEDIA_URL = ''
# URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a
# trailing slash.
# Examples: "http://foo.com/media/", "/media/".
ADMIN_MEDIA_PREFIX = '/media/'
# Make this unique, and don't share it with anybody.
SECRET_KEY = 'u@x-aj9(hoh#rb-^ymf#g2jx_hp0vj7u5#b@ag1n^seu9e!%cy'
# List of callables that know how to import templates from various sources.
TEMPLATE_LOADERS = (
'django.template.loaders.filesystem.Loader',
'django.template.loaders.app_directories.Loader',
# 'django.template.loaders.eggs.Loader',
)
MIDDLEWARE_CLASSES = (
'django.middleware.common.CommonMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
)
ROOT_URLCONF = 'urls'
TEMPLATE_DIRS = (
# Put strings here, like "/home/html/django_templates" or "C:/www/django/templates".
# Always use forward slashes, even on Windows.
# Don't forget to use absolute paths, not relative paths.
)
INSTALLED_APPS = (
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.sites',
'django.contrib.messages',
# Uncomment the next line to enable the admin:
# 'django.contrib.admin',
# Uncomment the next line to enable admin documentation:
# 'django.contrib.admindocs',
'djangorestframework',
)

16
testproject/urls.py Normal file
View File

@ -0,0 +1,16 @@
from django.conf.urls.defaults import *
# Uncomment the next two lines to enable the admin:
# from django.contrib import admin
# admin.autodiscover()
urlpatterns = patterns('',
# Example:
# (r'^testproject/', include('testproject.foo.urls')),
# Uncomment the admin/doc line below to enable admin documentation:
# (r'^admin/doc/', include('django.contrib.admindocs.urls')),
# Uncomment the next line to enable the admin:
# (r'^admin/', include(admin.site.urls)),
)