aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authortom christie tom@tomchristie.com2011-02-07 08:23:54 +0000
committertom christie tom@tomchristie.com2011-02-07 08:23:54 +0000
commit027ffed21064b1ec304a1ea559104382313d76f4 (patch)
tree8621c8741d76cc672da207cf314574a4fbd828d1
parenta8bcb2edc63564522b89b9950ea0882d6b25f24a (diff)
downloaddjango-rest-framework-027ffed21064b1ec304a1ea559104382313d76f4.tar.bz2
Refactor a bunch of stuff into mixins, more tests
-rw-r--r--djangorestframework/content.py14
-rw-r--r--djangorestframework/emitters.py41
-rw-r--r--djangorestframework/methods.py6
-rw-r--r--djangorestframework/parsers.py33
-rw-r--r--djangorestframework/resource.py126
-rw-r--r--djangorestframework/tests/content.py10
-rw-r--r--djangorestframework/tests/methods.py4
-rw-r--r--djangorestframework/validators.py153
8 files changed, 235 insertions, 152 deletions
diff --git a/djangorestframework/content.py b/djangorestframework/content.py
index 94b908f5..d612a2ee 100644
--- a/djangorestframework/content.py
+++ b/djangorestframework/content.py
@@ -29,10 +29,10 @@ class OverloadedContentMixin(ContentMixin):
"""HTTP request content behaviour that also allows arbitrary content to be tunneled in form data."""
"""The name to use for the content override field in the POST form."""
- FORM_PARAM_CONTENT = '_content'
+ CONTENT_PARAM = '_content'
"""The name to use for the content-type override field in the POST form."""
- FORM_PARAM_CONTENTTYPE = '_contenttype'
+ CONTENTTYPE_PARAM = '_contenttype'
def determine_content(self, request):
"""If the request contains content return a tuple of (content_type, content) otherwise return None.
@@ -42,14 +42,14 @@ class OverloadedContentMixin(ContentMixin):
content_type = request.META.get('CONTENT_TYPE', None)
- if (request.method == 'POST' and self.FORM_PARAM_CONTENT and
- request.POST.get(self.FORM_PARAM_CONTENT, None) is not None):
+ if (request.method == 'POST' and self.CONTENT_PARAM and
+ request.POST.get(self.CONTENT_PARAM, None) is not None):
# Set content type if form contains a none empty FORM_PARAM_CONTENTTYPE field
content_type = None
- if self.FORM_PARAM_CONTENTTYPE and request.POST.get(self.FORM_PARAM_CONTENTTYPE, None):
- content_type = request.POST.get(self.FORM_PARAM_CONTENTTYPE, None)
+ if self.CONTENTTYPE_PARAM and request.POST.get(self.CONTENTTYPE_PARAM, None):
+ content_type = request.POST.get(self.CONTENTTYPE_PARAM, None)
- return (content_type, request.POST[self.FORM_PARAM_CONTENT])
+ return (content_type, request.POST[self.CONTENT_PARAM])
return (content_type, request.raw_post_data) \ No newline at end of file
diff --git a/djangorestframework/emitters.py b/djangorestframework/emitters.py
index a69407f1..d6bb33c1 100644
--- a/djangorestframework/emitters.py
+++ b/djangorestframework/emitters.py
@@ -8,6 +8,7 @@ from django.template import RequestContext, loader
from django import forms
from djangorestframework.response import NoContent
+from djangorestframework.validators import FormValidatorMixin
from djangorestframework.utils import dict2xml, url_resolves
from urllib import quote_plus
@@ -82,24 +83,28 @@ class DocumentingTemplateEmitter(BaseEmitter):
In the absence on of the Resource having an associated form then
provide a form that can be used to submit arbitrary content."""
# Get the form instance if we have one bound to the input
- form_instance = resource.form_instance
-
- # Otherwise if this isn't an error response
- # then attempt to get a form bound to the response object
- if not form_instance and resource.response.has_content_body:
- try:
- form_instance = resource.get_form(resource.response.raw_content)
- if form_instance:
- form_instance.is_valid()
- 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_form()
- except:
- pass
+ #form_instance = resource.form_instance
+ # TODO! Reinstate this
+
+ 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 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()
+ 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()
+ except:
+ pass
# If we still don't have a form instance then try to get an unbound form which can tunnel arbitrary content types
if not form_instance:
diff --git a/djangorestframework/methods.py b/djangorestframework/methods.py
index 06a96643..088c563c 100644
--- a/djangorestframework/methods.py
+++ b/djangorestframework/methods.py
@@ -24,12 +24,12 @@ class OverloadedPOSTMethodMixin(MethodMixin):
"""Provide for overloaded POST behaviour."""
"""The name to use for the method override field in the POST form."""
- FORM_PARAM_METHOD = '_method'
+ METHOD_PARAM = '_method'
def determine_method(self, request):
"""Simply return GET, POST etc... as appropriate, allowing for POST overloading
by setting a form field with the requested method name."""
method = request.method.upper()
- if method == 'POST' and self.FORM_PARAM_METHOD and request.POST.has_key(self.FORM_PARAM_METHOD):
- method = request.POST[self.FORM_PARAM_METHOD].upper()
+ if method == 'POST' and self.METHOD_PARAM and request.POST.has_key(self.METHOD_PARAM):
+ method = request.POST[self.METHOD_PARAM].upper()
return method \ No newline at end of file
diff --git a/djangorestframework/parsers.py b/djangorestframework/parsers.py
index a656e2eb..0d5121e9 100644
--- a/djangorestframework/parsers.py
+++ b/djangorestframework/parsers.py
@@ -5,7 +5,38 @@ try:
except ImportError:
import simplejson as json
-# TODO: Make all parsers only list a single media_type, rather than a list
+
+class ParserMixin(object):
+ parsers = ()
+
+ def parse(self, content_type, content):
+ # See RFC 2616 sec 3 - http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7
+ split = content_type.split(';', 1)
+ if len(split) > 1:
+ content_type = split[0]
+ content_type = content_type.strip()
+
+ media_type_to_parser = dict([(parser.media_type, parser) for parser in self.parsers])
+
+ try:
+ parser = media_type_to_parser[content_type]
+ except KeyError:
+ raise ResponseException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
+ {'error': 'Unsupported media type in request \'%s\'.' % content_type})
+
+ return parser(self).parse(content)
+
+ @property
+ def parsed_media_types(self):
+ """Return an list of all the media types that this ParserMixin can parse."""
+ return [parser.media_type for parser in self.parsers]
+
+ @property
+ def default_parser(self):
+ """Return the ParerMixin's most prefered emitter.
+ (This has no behavioural effect, but is may be used by documenting emitters)"""
+ return self.parsers[0]
+
class BaseParser(object):
"""All parsers should extend BaseParser, specifing a media_type attribute,
diff --git a/djangorestframework/resource.py b/djangorestframework/resource.py
index 6277d22e..c743ce8f 100644
--- a/djangorestframework/resource.py
+++ b/djangorestframework/resource.py
@@ -2,6 +2,10 @@ from django.contrib.sites.models import Site
from django.core.urlresolvers import reverse
from django.http import HttpResponse
+from djangorestframework.parsers import ParserMixin
+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
@@ -20,7 +24,7 @@ __all__ = ['Resource']
_MSIE_USER_AGENT = re.compile(r'^Mozilla/[0-9]+\.[0-9]+ \([^)]*; MSIE [0-9]+\.[0-9]+[a-z]?;[^)]*\)(?!.* Opera )')
-class Resource(object):
+class Resource(ParserMixin, FormValidatorMixin, OverloadedContentMixin, OverloadedPOSTMethodMixin):
"""Handles incoming requests and maps them to REST operations,
performing authentication, input deserialization, input validation, output serialization."""
@@ -53,10 +57,7 @@ class Resource(object):
# 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
- METHOD_PARAM = '_method' # Allow POST overloading in form params
- CONTENTTYPE_PARAM = '_contenttype' # Allow override of Content-Type header in form params (allows sending arbitrary content with standard forms)
- CONTENT_PARAM = '_content' # Allow override of body content in form params (allows sending arbitrary content with standard forms)
+ 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
@@ -112,18 +113,6 @@ class Resource(object):
(This emitter is used if the client does not send and Accept: header, or sends Accept: */*)"""
return self.emitters[0]
- @property
- def parsed_media_types(self):
- """Return an list of all the media types that this resource can emit."""
- return [parser.media_type for parser in self.parsers]
-
- @property
- def default_parser(self):
- """Return the resource's most prefered emitter.
- (This has no behavioural effect, but is may be used by documenting emitters)"""
- return self.parsers[0]
-
-
def get(self, request, auth, *args, **kwargs):
"""Must be subclassed to be implemented."""
self.not_implemented('GET')
@@ -171,17 +160,6 @@ class Resource(object):
pass
return self.request.build_absolute_uri(path)
-
-
- def determine_method(self, request):
- """Determine the HTTP method that this request should be treated as.
- Allows PUT and DELETE tunneling via the _method parameter if METHOD_PARAM is set."""
- method = request.method.upper()
-
- if method == 'POST' and self.METHOD_PARAM and request.POST.has_key(self.METHOD_PARAM):
- method = request.POST[self.METHOD_PARAM].upper()
-
- return method
def authenticate(self, request):
@@ -214,58 +192,6 @@ class Resource(object):
{'detail': 'You do not have permission to access this resource. ' +
'You may need to login or otherwise authenticate the request.'})
- def get_form(self, data=None):
- """Optionally return a Django Form instance, which may be used for validation
- and/or rendered by an HTML/XHTML emitter.
-
- If data is not None the form will be bound to data."""
-
- if self.form:
- if data:
- return self.form(data)
- else:
- return self.form()
- return None
-
-
- def cleanup_request(self, data, form_instance):
- """Perform any resource-specific data deserialization and/or validation
- after the initial HTTP content-type deserialization has taken place.
-
- Returns a tuple containing the cleaned up data, and optionally a form bound to that data.
-
- By default this uses form validation to filter the basic input into the required types."""
-
- if form_instance is None:
- return data
-
- # Default form validation does not check for additional invalid fields
- non_existent_fields = []
- for key in set(data.keys()) - set(form_instance.fields.keys()):
- non_existent_fields.append(key)
-
- if not form_instance.is_valid() or non_existent_fields:
- if not form_instance.errors and not non_existent_fields:
- # If no data was supplied the errors property will be None
- details = 'No content was supplied'
-
- else:
- # Add standard field errors
- details = dict((key, map(unicode, val)) for (key, val) in form_instance.errors.iteritems() if key != '__all__')
-
- # Add any non-field errors
- if form_instance.non_field_errors():
- details['errors'] = form_instance.non_field_errors()
-
- # Add any non-existent field errors
- for key in non_existent_fields:
- details[key] = ['This field does not exist']
-
- # Bail. Note that we will still serialize this response with the appropriate content type
- raise ResponseException(status.HTTP_400_BAD_REQUEST, {'detail': details})
-
- return form_instance.cleaned_data
-
def cleanup_response(self, data):
"""Perform any resource-specific data filtering prior to the standard HTTP
@@ -275,37 +201,6 @@ class Resource(object):
return data
- def determine_parser(self, request):
- """Return the appropriate parser for the input, given the client's 'Content-Type' header,
- and the content types that this Resource knows how to parse."""
- content_type = request.META.get('CONTENT_TYPE', 'application/x-www-form-urlencoded')
- raw_content = request.raw_post_data
-
- split = content_type.split(';', 1)
- if len(split) > 1:
- content_type = split[0]
- content_type = content_type.strip()
-
- # If CONTENTTYPE_PARAM is turned on, and this is a standard POST form then allow the content type to be overridden
- if (content_type == 'application/x-www-form-urlencoded' and
- request.method == 'POST' and
- self.CONTENTTYPE_PARAM and
- self.CONTENT_PARAM and
- request.POST.get(self.CONTENTTYPE_PARAM, None) and
- request.POST.get(self.CONTENT_PARAM, None)):
- raw_content = request.POST[self.CONTENT_PARAM]
- content_type = request.POST[self.CONTENTTYPE_PARAM]
-
- # Create a list of list of (media_type, Parser) tuples
- media_type_to_parser = dict([(parser.media_type, parser) for parser in self.parsers])
-
- try:
- return (media_type_to_parser[content_type], raw_content)
- except KeyError:
- raise ResponseException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
- {'detail': 'Unsupported media type \'%s\'' % content_type})
-
-
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.
@@ -407,11 +302,10 @@ class Resource(object):
# 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.
if method in ('PUT', 'POST'):
- (parser, raw_content) = self.determine_parser(request)
- data = parser(self).parse(raw_content)
- self.form_instance = self.get_form(data)
- data = self.cleanup_request(data, self.form_instance)
- response = func(request, auth_context, data, *args, **kwargs)
+ (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)
else:
response = func(request, auth_context, *args, **kwargs)
diff --git a/djangorestframework/tests/content.py b/djangorestframework/tests/content.py
index 43769123..e3f2c41f 100644
--- a/djangorestframework/tests/content.py
+++ b/djangorestframework/tests/content.py
@@ -21,8 +21,8 @@ class TestContentMixins(TestCase):
def test_overloaded_content_mixin_interface(self):
"""Ensure the OverloadedContentMixin interface is as expected."""
self.assertTrue(issubclass(OverloadedContentMixin, ContentMixin))
- getattr(OverloadedContentMixin, 'FORM_PARAM_CONTENT')
- getattr(OverloadedContentMixin, 'FORM_PARAM_CONTENTTYPE')
+ getattr(OverloadedContentMixin, 'CONTENT_PARAM')
+ getattr(OverloadedContentMixin, 'CONTENTTYPE_PARAM')
getattr(OverloadedContentMixin, 'determine_content')
@@ -107,14 +107,14 @@ class TestContentMixins(TestCase):
"""Ensure determine_content(request) returns (content type, content) for overloaded POST request"""
content = 'qwerty'
content_type = 'text/plain'
- form_data = {OverloadedContentMixin.FORM_PARAM_CONTENT: content,
- OverloadedContentMixin.FORM_PARAM_CONTENTTYPE: content_type}
+ form_data = {OverloadedContentMixin.CONTENT_PARAM: content,
+ OverloadedContentMixin.CONTENTTYPE_PARAM: content_type}
request = self.req.post('/', form_data)
self.assertEqual(OverloadedContentMixin().determine_content(request), (content_type, content))
def test_overloaded_behaviour_allows_content_tunnelling_content_type_not_set(self):
"""Ensure determine_content(request) returns (None, content) for overloaded POST request with content type not set"""
content = 'qwerty'
- request = self.req.post('/', {OverloadedContentMixin.FORM_PARAM_CONTENT: content})
+ request = self.req.post('/', {OverloadedContentMixin.CONTENT_PARAM: content})
self.assertEqual(OverloadedContentMixin().determine_content(request), (None, content))
diff --git a/djangorestframework/tests/methods.py b/djangorestframework/tests/methods.py
index e580ba02..833457f5 100644
--- a/djangorestframework/tests/methods.py
+++ b/djangorestframework/tests/methods.py
@@ -21,7 +21,7 @@ class TestMethodMixins(TestCase):
def test_overloaded_method_mixin_interface(self):
"""Ensure the OverloadedPOSTMethodMixin interface is as expected."""
self.assertTrue(issubclass(OverloadedPOSTMethodMixin, MethodMixin))
- getattr(OverloadedPOSTMethodMixin, 'FORM_PARAM_METHOD')
+ getattr(OverloadedPOSTMethodMixin, 'METHOD_PARAM')
getattr(OverloadedPOSTMethodMixin, 'determine_method')
# Behavioural tests
@@ -48,5 +48,5 @@ class TestMethodMixins(TestCase):
def test_overloaded_POST_behaviour_determines_overloaded_method(self):
"""POST requests can be overloaded to another method by setting a reserved form field with OverloadedPOSTMethodMixin"""
- request = self.req.post('/', {OverloadedPOSTMethodMixin.FORM_PARAM_METHOD: 'DELETE'})
+ request = self.req.post('/', {OverloadedPOSTMethodMixin.METHOD_PARAM: 'DELETE'})
self.assertEqual(OverloadedPOSTMethodMixin().determine_method(request), 'DELETE')
diff --git a/djangorestframework/validators.py b/djangorestframework/validators.py
new file mode 100644
index 00000000..aefad4b3
--- /dev/null
+++ b/djangorestframework/validators.py
@@ -0,0 +1,153 @@
+"""Mixin classes that provide a validate(content) function to validate and cleanup request content"""
+from django import forms
+from django.db import models
+from djangorestframework.response import ResponseException
+from djangorestframework.utils import as_tuple
+
+class ValidatorMixin(object):
+ """Base class for all ValidatorMixin classes, which simply defines the interface they provide."""
+
+ def validate(self, content):
+ """Given some content as input return some cleaned, validated content.
+ Raises a ResponseException with status code 400 (Bad Request) on failure.
+
+ Must be overridden to be implemented."""
+ raise NotImplementedError()
+
+
+class FormValidatorMixin(ValidatorMixin):
+ """Validator Mixin that uses forms for validation.
+ Extends the ValidatorMixin interface to also provide a get_bound_form() method.
+ (Which may be used by some emitters.)"""
+
+ """The form class that should be used for validation, or None to turn off form validation."""
+ form = None
+
+ def validate(self, content):
+ """Given some content as input return some cleaned, validated content.
+ Raises a ResponseException with status code 400 (Bad Request) on failure.
+
+ Validation is standard form validation, with an additional constraint that no extra unknown fields may be supplied.
+
+ 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}."""
+ return self._validate(content)
+
+ def _validate(self, content, 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)
+
+ # And that any extra fields we have specified are all present.
+ missing_extra_fields = set(extra_fields) - set(content.keys())
+
+ # 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
+
+ # Validation failed...
+ detail = {}
+
+ if not bound_form.errors and not unknown_fields and not missing_extra_fields:
+ detail = {u'errors': [u'No content was supplied.']}
+
+ else:
+ # Add any non-field errors
+ if bound_form.non_field_errors():
+ detail[u'errors'] = bound_form.non_field_errors()
+
+ # Add standard field errors
+ field_errors = dict((key, map(unicode, val)) for (key, val) in bound_form.errors.iteritems() if not key.startswith('__'))
+
+ # 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
+
+ # Return HTTP 400 response (BAD REQUEST)
+ raise ResponseException(400, detail)
+
+
+ def get_bound_form(self, content=None):
+ """Given some content return a Django form bound to that content.
+ If form validation is turned off (form class attribute is None) then returns None."""
+ if not self.form:
+ return None
+
+ if content:
+ return self.form(content)
+ return self.form()
+
+
+class ModelFormValidatorMixin(FormValidatorMixin):
+ """Validator Mixin that uses forms for validation and falls back to a model form if no form is set.
+ Extends the ValidatorMixin interface to also provide a get_bound_form() method.
+ (Which may be used by some emitters.)"""
+
+ """The form class that should be used for validation, or None to use model form validation."""
+ form = None
+
+ """The model class from which the model form should be constructed if no form is set."""
+ 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."""
+ fields = None
+
+ # 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.)
+ def validate(self, content):
+ """Given some content as input return some cleaned, validated content.
+ Raises a ResponseException with status code 400 (Bad Request) on failure.
+
+ Validation is standard form or model form validation,
+ with an additional constraint that no extra unknown fields may be supplied,
+ and that all fields specified by the fields class attribute must be supplied,
+ even if they are not validated by the form/model form.
+
+ 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)
+
+
+ def get_bound_form(self, content=None):
+ """Given some content return a Django form bound to that content.
+
+ 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 Meta:
+ model = self.model
+ fields = tuple(set.intersection(self.model._meta.fields, self.fields))
+
+ # Instantiate the ModelForm as appropriate
+ if content and isinstance(content, models.Model):
+ return ModelForm(instance=content)
+ elif content:
+ return ModelForm(content)
+ return ModelForm()
+
+ # Both form and model not set? Okay bruv, whatevs...
+ return None \ No newline at end of file