diff --git a/djangorestframework/content.py b/djangorestframework/content.py new file mode 100644 index 000000000..94b908f5b --- /dev/null +++ b/djangorestframework/content.py @@ -0,0 +1,55 @@ +"""Mixin classes that provide a determine_content(request) method to return the content type and content of a request. +We use this more generic behaviour to allow for overloaded content in POST forms. +""" + +class ContentMixin(object): + """Base class for all ContentMixin classes, which simply defines the interface they provide.""" + + def determine_content(self, request): + """If the request contains content return a tuple of (content_type, content) otherwise return None. + Note that content_type may be None if it is unset. + Must be overridden to be implemented.""" + raise NotImplementedError() + + +class StandardContentMixin(ContentMixin): + """Standard HTTP request content behaviour. + See RFC 2616 sec 4.3 - http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.3""" + + def determine_content(self, request): + """If the request contains content return a tuple of (content_type, content) otherwise return None. + Note that content_type may be None if it is unset.""" + + if not request.META.get('CONTENT_LENGTH', None) and not request.META.get('TRANSFER_ENCODING', None): + return None + return (request.META.get('CONTENT_TYPE', None), request.raw_post_data) + + +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' + + """The name to use for the content-type override field in the POST form.""" + FORM_PARAM_CONTENTTYPE = '_contenttype' + + def determine_content(self, request): + """If the request contains content return a tuple of (content_type, content) otherwise return None. + Note that content_type may be None if it is unset.""" + if not request.META.get('CONTENT_LENGTH', None) and not request.META.get('TRANSFER_ENCODING', None): + return None + + 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): + + # 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) + + return (content_type, request.POST[self.FORM_PARAM_CONTENT]) + + return (content_type, request.raw_post_data) \ No newline at end of file diff --git a/djangorestframework/methods.py b/djangorestframework/methods.py new file mode 100644 index 000000000..06a966434 --- /dev/null +++ b/djangorestframework/methods.py @@ -0,0 +1,35 @@ +"""Mixin classes that provide a determine_method(request) function to determine the HTTP +method that a given request should be treated as. We use this more generic behaviour to +allow for overloaded methods in POST forms. + +See Richardson & Ruby's RESTful Web Services for justification. +""" + +class MethodMixin(object): + """Base class for all MethodMixin classes, which simply defines the interface they provide.""" + def determine_method(self, request): + """Simply return GET, POST etc... as appropriate.""" + raise NotImplementedError() + + +class StandardMethodMixin(MethodMixin): + """Provide for standard HTTP behaviour, with no overloaded POST.""" + + def determine_method(self, request): + """Simply return GET, POST etc... as appropriate.""" + return request.method.upper() + + +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' + + 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() + return method \ No newline at end of file diff --git a/djangorestframework/response.py b/djangorestframework/response.py index 4f23bb0a2..e807eeb52 100644 --- a/djangorestframework/response.py +++ b/djangorestframework/response.py @@ -106,7 +106,7 @@ class NoContent(object): class Response(object): - def __init__(self, status, content=NoContent, headers={}): + def __init__(self, status=200, content=NoContent, headers={}): self.status = status self.has_content_body = not content is NoContent self.raw_content = content # content prior to filtering diff --git a/djangorestframework/tests/__init__.py b/djangorestframework/tests/__init__.py new file mode 100644 index 000000000..7ac544196 --- /dev/null +++ b/djangorestframework/tests/__init__.py @@ -0,0 +1,9 @@ +"""Force import of all modules in this package in order to get the standard test runner to pick up the tests. Yowzers.""" +import os + +modules = [filename.rsplit('.', 1)[0] + for filename in os.listdir(os.path.dirname(__file__)) + if filename.endswith('.py') and not filename.startswith('_')] + +for module in modules: + exec("from djangorestframework.tests.%s import *" % module) \ No newline at end of file diff --git a/djangorestframework/tests/accept.py b/djangorestframework/tests/accept.py new file mode 100644 index 000000000..c4964e8ab --- /dev/null +++ b/djangorestframework/tests/accept.py @@ -0,0 +1,64 @@ +from django.test import TestCase +from djangorestframework.tests.utils import RequestFactory +from djangorestframework.resource import Resource + + +# 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.req = 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.req.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.req.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.req.get('/', HTTP_ACCEPT='*/*', HTTP_USER_AGENT=user_agent) + resp = self.MockResource(req) + self.assertEqual(resp['Content-Type'], 'application/json') + + + + diff --git a/djangorestframework/tests/content.py b/djangorestframework/tests/content.py new file mode 100644 index 000000000..437691234 --- /dev/null +++ b/djangorestframework/tests/content.py @@ -0,0 +1,120 @@ +from django.test import TestCase +from djangorestframework.tests.utils import RequestFactory +from djangorestframework.content import ContentMixin, StandardContentMixin, OverloadedContentMixin + + +class TestContentMixins(TestCase): + def setUp(self): + self.req = RequestFactory() + + # Interface tests + + def test_content_mixin_interface(self): + """Ensure the ContentMixin interface is as expected.""" + self.assertRaises(NotImplementedError, ContentMixin().determine_content, None) + + def test_standard_content_mixin_interface(self): + """Ensure the OverloadedContentMixin interface is as expected.""" + self.assertTrue(issubclass(StandardContentMixin, ContentMixin)) + getattr(StandardContentMixin, 'determine_content') + + 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, 'determine_content') + + + # Common functionality to test with both StandardContentMixin and OverloadedContentMixin + + def ensure_determines_no_content_GET(self, mixin): + """Ensure determine_content(request) returns None for GET request with no content.""" + request = self.req.get('/') + self.assertEqual(mixin.determine_content(request), None) + + def ensure_determines_form_content_POST(self, mixin): + """Ensure determine_content(request) returns content for POST request with content.""" + form_data = {'qwerty': 'uiop'} + request = self.req.post('/', data=form_data) + self.assertEqual(mixin.determine_content(request), (request.META['CONTENT_TYPE'], request.raw_post_data)) + + def ensure_determines_non_form_content_POST(self, mixin): + """Ensure determine_content(request) returns (content type, content) for POST request with content.""" + content = 'qwerty' + content_type = 'text/plain' + request = self.req.post('/', content, content_type=content_type) + self.assertEqual(mixin.determine_content(request), (content_type, content)) + + def ensure_determines_form_content_PUT(self, mixin): + """Ensure determine_content(request) returns content for PUT request with content.""" + form_data = {'qwerty': 'uiop'} + request = self.req.put('/', data=form_data) + self.assertEqual(mixin.determine_content(request), (request.META['CONTENT_TYPE'], request.raw_post_data)) + + def ensure_determines_non_form_content_PUT(self, mixin): + """Ensure determine_content(request) returns (content type, content) for PUT request with content.""" + content = 'qwerty' + content_type = 'text/plain' + request = self.req.put('/', content, content_type=content_type) + self.assertEqual(mixin.determine_content(request), (content_type, content)) + + # StandardContentMixin behavioural tests + + def test_standard_behaviour_determines_no_content_GET(self): + """Ensure StandardContentMixin.determine_content(request) returns None for GET request with no content.""" + self.ensure_determines_no_content_GET(StandardContentMixin()) + + def test_standard_behaviour_determines_form_content_POST(self): + """Ensure StandardContentMixin.determine_content(request) returns content for POST request with content.""" + self.ensure_determines_form_content_POST(StandardContentMixin()) + + def test_standard_behaviour_determines_non_form_content_POST(self): + """Ensure StandardContentMixin.determine_content(request) returns (content type, content) for POST request with content.""" + self.ensure_determines_non_form_content_POST(StandardContentMixin()) + + def test_standard_behaviour_determines_form_content_PUT(self): + """Ensure StandardContentMixin.determine_content(request) returns content for PUT request with content.""" + self.ensure_determines_form_content_PUT(StandardContentMixin()) + + def test_standard_behaviour_determines_non_form_content_PUT(self): + """Ensure StandardContentMixin.determine_content(request) returns (content type, content) for PUT request with content.""" + self.ensure_determines_non_form_content_PUT(StandardContentMixin()) + + # OverloadedContentMixin behavioural tests + + def test_overloaded_behaviour_determines_no_content_GET(self): + """Ensure StandardContentMixin.determine_content(request) returns None for GET request with no content.""" + self.ensure_determines_no_content_GET(OverloadedContentMixin()) + + def test_overloaded_behaviour_determines_form_content_POST(self): + """Ensure StandardContentMixin.determine_content(request) returns content for POST request with content.""" + self.ensure_determines_form_content_POST(OverloadedContentMixin()) + + def test_overloaded_behaviour_determines_non_form_content_POST(self): + """Ensure StandardContentMixin.determine_content(request) returns (content type, content) for POST request with content.""" + self.ensure_determines_non_form_content_POST(OverloadedContentMixin()) + + def test_overloaded_behaviour_determines_form_content_PUT(self): + """Ensure StandardContentMixin.determine_content(request) returns content for PUT request with content.""" + self.ensure_determines_form_content_PUT(OverloadedContentMixin()) + + def test_overloaded_behaviour_determines_non_form_content_PUT(self): + """Ensure StandardContentMixin.determine_content(request) returns (content type, content) for PUT request with content.""" + self.ensure_determines_non_form_content_PUT(OverloadedContentMixin()) + + def test_overloaded_behaviour_allows_content_tunnelling(self): + """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} + 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}) + self.assertEqual(OverloadedContentMixin().determine_content(request), (None, content)) + diff --git a/djangorestframework/tests/methods.py b/djangorestframework/tests/methods.py new file mode 100644 index 000000000..e580ba027 --- /dev/null +++ b/djangorestframework/tests/methods.py @@ -0,0 +1,52 @@ +from django.test import TestCase +from djangorestframework.tests.utils import RequestFactory +from djangorestframework.methods import MethodMixin, StandardMethodMixin, OverloadedPOSTMethodMixin + + +class TestMethodMixins(TestCase): + def setUp(self): + self.req = RequestFactory() + + # Interface tests + + def test_method_mixin_interface(self): + """Ensure the base ContentMixin interface is as expected.""" + self.assertRaises(NotImplementedError, MethodMixin().determine_method, None) + + def test_standard_method_mixin_interface(self): + """Ensure the StandardMethodMixin interface is as expected.""" + self.assertTrue(issubclass(StandardMethodMixin, MethodMixin)) + getattr(StandardMethodMixin, 'determine_method') + + 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, 'determine_method') + + # Behavioural tests + + def test_standard_behaviour_determines_GET(self): + """GET requests identified as GET method with StandardMethodMixin""" + request = self.req.get('/') + self.assertEqual(StandardMethodMixin().determine_method(request), 'GET') + + def test_standard_behaviour_determines_POST(self): + """POST requests identified as POST method with StandardMethodMixin""" + request = self.req.post('/') + self.assertEqual(StandardMethodMixin().determine_method(request), 'POST') + + def test_overloaded_POST_behaviour_determines_GET(self): + """GET requests identified as GET method with OverloadedPOSTMethodMixin""" + request = self.req.get('/') + self.assertEqual(OverloadedPOSTMethodMixin().determine_method(request), 'GET') + + def test_overloaded_POST_behaviour_determines_POST(self): + """POST requests identified as POST method with OverloadedPOSTMethodMixin""" + request = self.req.post('/') + self.assertEqual(OverloadedPOSTMethodMixin().determine_method(request), 'POST') + + 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'}) + self.assertEqual(OverloadedPOSTMethodMixin().determine_method(request), 'DELETE') diff --git a/djangorestframework/tests/response.py b/djangorestframework/tests/response.py new file mode 100644 index 000000000..c199f3005 --- /dev/null +++ b/djangorestframework/tests/response.py @@ -0,0 +1,25 @@ +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') + diff --git a/djangorestframework/tests/utils.py b/djangorestframework/tests/utils.py new file mode 100644 index 000000000..ef0cb59cf --- /dev/null +++ b/djangorestframework/tests/utils.py @@ -0,0 +1,40 @@ +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) diff --git a/djangorestframework/tests/validators.py b/djangorestframework/tests/validators.py new file mode 100644 index 000000000..f72ea60dc --- /dev/null +++ b/djangorestframework/tests/validators.py @@ -0,0 +1,151 @@ +from django import forms +from django.test import TestCase +from djangorestframework.tests.utils 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 + + def test_validator_mixin_interface(self): + """Ensure the ContentMixin interface is as expected.""" + self.assertRaises(NotImplementedError, ValidatorMixin().validate, None) + + def test_form_validator_mixin_interface(self): + """Ensure the OverloadedContentMixin 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.""" + self.assertTrue(issubclass(ModelFormValidatorMixin, FormValidatorMixin)) + getattr(ModelFormValidatorMixin, 'model') + getattr(ModelFormValidatorMixin, 'form') + 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.""" + content = {'qwerty':'uiop'} + self.assertEqual(self.DisabledValidator().validate(content), content) + + 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) + + 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_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) + + 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_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',)) + + 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 + + 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""" + class MockForm(forms.Form): + field1 = forms.CharField(required=False) + field2 = forms.CharField(required=False) + ERROR_TEXT = 'You may not supply both field1 and field2' + + def clean(self): + if 'field1' in self.cleaned_data and 'field2' in self.cleaned_data: + raise forms.ValidationError(self.ERROR_TEXT) + return self.cleaned_data #pragma: no cover + + class MockValidator(FormValidatorMixin): + form = MockForm + + content = {'field1': 'example1', 'field2': 'example2'} + try: + MockValidator().validate(content) + except ResponseException, exc: + self.assertEqual(exc.response.raw_content, {'errors': [MockForm.ERROR_TEXT]}) + else: + self.fail('ResourceException was not raised') #pragma: no cover \ No newline at end of file diff --git a/djangorestframework/utils.py b/djangorestframework/utils.py index f9bbc0fe6..bc797d60f 100644 --- a/djangorestframework/utils.py +++ b/djangorestframework/utils.py @@ -9,6 +9,17 @@ except ImportError: import StringIO +def as_tuple(obj): + """Given obj return a tuple""" + if obj is None: + return () + elif isinstance(obj, list): + return tuple(obj) + elif isinstance(obj, tuple): + return obj + return (obj,) + + def url_resolves(url): """Return True if the given URL is mapped to a view in the urlconf, False otherwise.""" try: diff --git a/docs/templates/layout.html b/docs/templates/layout.html index 86ac3f33c..6ba9e6ff0 100644 --- a/docs/templates/layout.html +++ b/docs/templates/layout.html @@ -8,4 +8,19 @@ {% block htmltitle %}{% if pagename == 'index' %}Django REST framework{% else %}{{ titleprefix }}{{ title|striptags|e }}{% endif %}{% endblock %} +{% block extrahead %} +{{ super() }} + +{% endblock %}