From ec2a300a2bce53486d00003e7c8e28b6147eaa27 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 11 Apr 2011 11:21:41 +0100 Subject: [PATCH 01/70] Start refactoring tests --- djangorestframework/tests/content.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/djangorestframework/tests/content.py b/djangorestframework/tests/content.py index c5eae2f97..31fcf892e 100644 --- a/djangorestframework/tests/content.py +++ b/djangorestframework/tests/content.py @@ -1,13 +1,14 @@ # TODO: refactor these tests -#from django.test import TestCase -#from djangorestframework.compat import RequestFactory +from django.test import TestCase +from djangorestframework.compat import RequestFactory +from djangorestframework.request import RequestMixin #from djangorestframework.content import ContentMixin, StandardContentMixin, OverloadedContentMixin # # -#class TestContentMixins(TestCase): -# def setUp(self): -# self.req = RequestFactory() -# +class TestContentMixins(TestCase): + def setUp(self): + self.req = RequestFactory() + # # Interface tests # # def test_content_mixin_interface(self): @@ -29,10 +30,10 @@ # # # 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_no_content_GET(self, view): + """Ensure determine_content(request) returns None for GET request with no content.""" + view.request = self.req.get('/') + self.assertEqual(view.RAW_CONTENT, None) # # def ensure_determines_form_content_POST(self, mixin): # """Ensure determine_content(request) returns content for POST request with content.""" @@ -62,9 +63,9 @@ # # # 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_no_content_GET(self): + """Ensure StandardContentMixin.determine_content(request) returns None for GET request with no content.""" + self.ensure_determines_no_content_GET(RequestMixin()) # # def test_standard_behaviour_determines_form_content_POST(self): # """Ensure StandardContentMixin.determine_content(request) returns content for POST request with content.""" From 2fe0e584354a6625b0b293aebc7a7820ad50213e Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 11 Apr 2011 11:24:14 +0100 Subject: [PATCH 02/70] Start refactoring tests --- djangorestframework/request.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/djangorestframework/request.py b/djangorestframework/request.py index c4381bbf8..c5d982860 100644 --- a/djangorestframework/request.py +++ b/djangorestframework/request.py @@ -145,6 +145,39 @@ class RequestMixin(object): self._stream = StringIO(content[self.CONTENT_PARAM]) del(self._raw_content) + def parse(self, stream, content_type): + """ + Parse the request content. + + May raise a 415 ResponseException (Unsupported Media Type), + or a 400 ResponseException (Bad Request). + """ + parsers = as_tuple(self.parsers) + + parser = None + for parser_cls in parsers: + if parser_cls.handles(content_type): + parser = parser_cls(self) + break + + if parser is None: + raise ResponseException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + {'error': 'Unsupported media type in request \'%s\'.' % + content_type.media_type}) + + return parser.parse(stream) + + @property + def parsed_media_types(self): + """Return an list of all the media types that this view can parse.""" + return [parser.media_type for parser in self.parsers] + + @property + def default_parser(self): + """Return the view's most preffered emitter. + (This has no behavioural effect, but is may be used by documenting emitters)""" + return self.parsers[0] + method = property(_get_method, _set_method) content_type = property(_get_content_type, _set_content_type) accept = property(_get_accept, _set_accept) From 338b5213fa6232dc6115b214495cf13ded6a3a17 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 11 Apr 2011 11:47:22 +0100 Subject: [PATCH 03/70] More tests passing --- djangorestframework/request.py | 23 +++++++++++++++++++++-- djangorestframework/tests/content.py | 28 +++++++++++++++------------- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/djangorestframework/request.py b/djangorestframework/request.py index c5d982860..f79354a1e 100644 --- a/djangorestframework/request.py +++ b/djangorestframework/request.py @@ -1,4 +1,8 @@ from djangorestframework.mediatypes import MediaType +from djangorestframework.utils import as_tuple +from djangorestframework.response import ResponseException +from djangorestframework import status + #from djangorestframework.requestparsing import parse, load_parser from django.http.multipartparser import LimitBytes from StringIO import StringIO @@ -11,6 +15,8 @@ class RequestMixin(object): CONTENTTYPE_PARAM = "_content_type" CONTENT_PARAM = "_content" + parsers = () + def _get_method(self): """ Returns the HTTP method for the current view. @@ -33,7 +39,10 @@ class RequestMixin(object): """ if not hasattr(self, '_content_type'): content_type = self.request.META.get('HTTP_CONTENT_TYPE', self.request.META.get('CONTENT_TYPE', '')) - self._content_type = MediaType(content_type) + if content_type: + self._content_type = MediaType(content_type) + else: + self._content_type = None return self._content_type @@ -68,8 +77,15 @@ class RequestMixin(object): if not hasattr(self, '_stream'): request = self.request + try: + content_length = int(request.META.get('CONTENT_LENGTH', request.META.get('HTTP_CONTENT_LENGTH'))) + except (ValueError, TypeError): + content_length = 0 + # Currently only supports parsing request body as a stream with 1.3 - if hasattr(request, 'read'): + if content_length == 0: + return None + elif hasattr(request, 'read'): # It's not at all clear if this needs to be byte limited or not. # Maybe I'm just being dumb but it looks to me like there's some issues # with that in Django. @@ -152,6 +168,9 @@ class RequestMixin(object): May raise a 415 ResponseException (Unsupported Media Type), or a 400 ResponseException (Bad Request). """ + if stream is None or content_type is None: + return None + parsers = as_tuple(self.parsers) parser = None diff --git a/djangorestframework/tests/content.py b/djangorestframework/tests/content.py index 31fcf892e..5e77472da 100644 --- a/djangorestframework/tests/content.py +++ b/djangorestframework/tests/content.py @@ -2,6 +2,7 @@ from django.test import TestCase from djangorestframework.compat import RequestFactory from djangorestframework.request import RequestMixin +from djangorestframework.parsers import FormParser, MultipartParser #from djangorestframework.content import ContentMixin, StandardContentMixin, OverloadedContentMixin # # @@ -31,16 +32,17 @@ class TestContentMixins(TestCase): # # Common functionality to test with both StandardContentMixin and OverloadedContentMixin # def ensure_determines_no_content_GET(self, view): - """Ensure determine_content(request) returns None for GET request with no content.""" + """Ensure view.RAW_CONTENT returns None for GET request with no content.""" view.request = self.req.get('/') self.assertEqual(view.RAW_CONTENT, 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_form_content_POST(self, view): + """Ensure determine_content(request) returns content for POST request with content.""" + form_data = {'qwerty': 'uiop'} + view.parsers = (FormParser, MultipartParser) + view.request = self.req.post('/', data=form_data) + self.assertEqual(view.RAW_CONTENT, form_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' @@ -64,12 +66,12 @@ class TestContentMixins(TestCase): # # 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.""" + """Ensure request.RAW_CONTENT returns None for GET request with no content.""" self.ensure_determines_no_content_GET(RequestMixin()) -# -# 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_form_content_POST(self): + """Ensure request.RAW_CONTENT returns content for POST request with content.""" + self.ensure_determines_form_content_POST(RequestMixin()) # # def test_standard_behaviour_determines_non_form_content_POST(self): # """Ensure StandardContentMixin.determine_content(request) returns (content type, content) for POST request with content.""" From d4ed17845650d9a548ff0df362f3469878db2f91 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 11 Apr 2011 11:54:26 +0100 Subject: [PATCH 04/70] More tests passing --- djangorestframework/parsers.py | 43 +++++------------- djangorestframework/tests/content.py | 67 ++++++++++++++-------------- 2 files changed, 46 insertions(+), 64 deletions(-) diff --git a/djangorestframework/parsers.py b/djangorestframework/parsers.py index 1503342c7..5b2366475 100644 --- a/djangorestframework/parsers.py +++ b/djangorestframework/parsers.py @@ -27,38 +27,7 @@ except ImportError: class ParserMixin(object): parsers = () - def parse(self, stream, content_type): - """ - Parse the request content. - May raise a 415 ResponseException (Unsupported Media Type), - or a 400 ResponseException (Bad Request). - """ - parsers = as_tuple(self.parsers) - - parser = None - for parser_cls in parsers: - if parser_cls.handles(content_type): - parser = parser_cls(self) - break - - if parser is None: - raise ResponseException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, - {'error': 'Unsupported media type in request \'%s\'.' % - content_type.media_type}) - - return parser.parse(stream) - - @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): @@ -122,6 +91,18 @@ class DataFlatener(object): return False +class PlainTextParser(BaseParser): + """ + Plain text parser. + + Simply returns the content of the stream + """ + media_type = MediaType('text/plain') + + def parse(self, stream): + return stream.read() + + class FormParser(BaseParser, DataFlatener): """The default parser for form data. Return a dict containing a single value for each non-reserved parameter. diff --git a/djangorestframework/tests/content.py b/djangorestframework/tests/content.py index 5e77472da..c4f93ef20 100644 --- a/djangorestframework/tests/content.py +++ b/djangorestframework/tests/content.py @@ -37,50 +37,51 @@ class TestContentMixins(TestCase): self.assertEqual(view.RAW_CONTENT, None) def ensure_determines_form_content_POST(self, view): - """Ensure determine_content(request) returns content for POST request with content.""" + """Ensure view.RAW_CONTENT returns content for POST request with form content.""" form_data = {'qwerty': 'uiop'} view.parsers = (FormParser, MultipartParser) view.request = self.req.post('/', data=form_data) self.assertEqual(view.RAW_CONTENT, form_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 ensure_determines_non_form_content_POST(self, mixin): + """Ensure view.RAW_CONTENT returns content for POST request with non-form content.""" + content = 'qwerty' + content_type = 'text/plain' + view.parsers = (PlainTextParser,) + view.request = self.req.post('/', content, content_type=content_type) + self.assertEqual(view.RAW_CONTENT, form_data) + + def ensure_determines_form_content_PUT(self, mixin): + """Ensure view.RAW_CONTENT returns content for PUT request with form content.""" + form_data = {'qwerty': 'uiop'} + view.parsers = (FormParser, MultipartParser) + view.request = self.req.put('/', data=form_data) + self.assertEqual(view.RAW_CONTENT, form_data) + + def ensure_determines_non_form_content_PUT(self, mixin): + """Ensure view.RAW_CONTENT returns content for PUT request with non-form content.""" + content = 'qwerty' + content_type = 'text/plain' + view.parsers = (PlainTextParser,) + view.request = self.req.post('/', content, content_type=content_type) + self.assertEqual(view.RAW_CONTENT, form_data)# + def test_standard_behaviour_determines_no_content_GET(self): """Ensure request.RAW_CONTENT returns None for GET request with no content.""" self.ensure_determines_no_content_GET(RequestMixin()) def test_standard_behaviour_determines_form_content_POST(self): - """Ensure request.RAW_CONTENT returns content for POST request with content.""" + """Ensure request.RAW_CONTENT returns content for POST request with form content.""" self.ensure_determines_form_content_POST(RequestMixin()) -# -# 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_POST(self): + """Ensure StandardContentMixin.determine_content(request) returns (content type, content) for POST request with content.""" + self.ensure_determines_non_form_content_POST(RequestMixin()) + + 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(RequestMixin()) + # 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()) From e29a3f4cf12ea4c7cc616f27441b44639c736334 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 11 Apr 2011 11:56:07 +0100 Subject: [PATCH 05/70] More tests passing --- djangorestframework/tests/content.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/djangorestframework/tests/content.py b/djangorestframework/tests/content.py index c4f93ef20..2e33fd877 100644 --- a/djangorestframework/tests/content.py +++ b/djangorestframework/tests/content.py @@ -2,7 +2,7 @@ from django.test import TestCase from djangorestframework.compat import RequestFactory from djangorestframework.request import RequestMixin -from djangorestframework.parsers import FormParser, MultipartParser +from djangorestframework.parsers import FormParser, MultipartParser, PlainTextParser #from djangorestframework.content import ContentMixin, StandardContentMixin, OverloadedContentMixin # # @@ -43,28 +43,28 @@ class TestContentMixins(TestCase): view.request = self.req.post('/', data=form_data) self.assertEqual(view.RAW_CONTENT, form_data) - def ensure_determines_non_form_content_POST(self, mixin): + def ensure_determines_non_form_content_POST(self, view): """Ensure view.RAW_CONTENT returns content for POST request with non-form content.""" content = 'qwerty' content_type = 'text/plain' view.parsers = (PlainTextParser,) view.request = self.req.post('/', content, content_type=content_type) - self.assertEqual(view.RAW_CONTENT, form_data) + self.assertEqual(view.RAW_CONTENT, content) - def ensure_determines_form_content_PUT(self, mixin): + def ensure_determines_form_content_PUT(self, view): """Ensure view.RAW_CONTENT returns content for PUT request with form content.""" form_data = {'qwerty': 'uiop'} view.parsers = (FormParser, MultipartParser) view.request = self.req.put('/', data=form_data) self.assertEqual(view.RAW_CONTENT, form_data) - def ensure_determines_non_form_content_PUT(self, mixin): + def ensure_determines_non_form_content_PUT(self, view): """Ensure view.RAW_CONTENT returns content for PUT request with non-form content.""" content = 'qwerty' content_type = 'text/plain' view.parsers = (PlainTextParser,) view.request = self.req.post('/', content, content_type=content_type) - self.assertEqual(view.RAW_CONTENT, form_data)# + self.assertEqual(view.RAW_CONTENT, content) def test_standard_behaviour_determines_no_content_GET(self): """Ensure request.RAW_CONTENT returns None for GET request with no content.""" From 0fe8d1a15dab8c1da37b4e966ccfe9095f24fa76 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 11 Apr 2011 12:19:28 +0100 Subject: [PATCH 06/70] form overloading tests passing --- djangorestframework/mediatypes.py | 2 +- djangorestframework/request.py | 8 ++++++ djangorestframework/tests/content.py | 38 +++++++++++++++------------- 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/djangorestframework/mediatypes.py b/djangorestframework/mediatypes.py index d1641a8fc..92d9264c3 100644 --- a/djangorestframework/mediatypes.py +++ b/djangorestframework/mediatypes.py @@ -63,7 +63,7 @@ class MediaType(object): """ return self.media_type == 'application/x-www-form-urlencoded' or \ self.media_type == 'multipart/form-data' - + def as_tuple(self): return (self.main_type, self.sub_type, self.params) diff --git a/djangorestframework/request.py b/djangorestframework/request.py index f79354a1e..988c0592f 100644 --- a/djangorestframework/request.py +++ b/djangorestframework/request.py @@ -1,6 +1,7 @@ from djangorestframework.mediatypes import MediaType from djangorestframework.utils import as_tuple from djangorestframework.response import ResponseException +from djangorestframework.parsers import FormParser, MultipartParser from djangorestframework import status #from djangorestframework.requestparsing import parse, load_parser @@ -151,11 +152,18 @@ class RequestMixin(object): if not self.USE_FORM_OVERLOADING or self.method != 'POST' or not self.content_type.is_form(): return + # Temporarily switch to using the form parsers, then parse the content + parsers = self.parsers + self.parsers = (FormParser, MultipartParser) content = self.RAW_CONTENT + self.parsers = parsers + + # Method overloading - change the method and remove the param from the content if self.METHOD_PARAM in content: self.method = content[self.METHOD_PARAM].upper() del self._raw_content[self.METHOD_PARAM] + # Content overloading - rewind the stream and modify the content type if self.CONTENT_PARAM in content and self.CONTENTTYPE_PARAM in content: self._content_type = MediaType(content[self.CONTENTTYPE_PARAM]) self._stream = StringIO(content[self.CONTENT_PARAM]) diff --git a/djangorestframework/tests/content.py b/djangorestframework/tests/content.py index 2e33fd877..05679b2f3 100644 --- a/djangorestframework/tests/content.py +++ b/djangorestframework/tests/content.py @@ -67,25 +67,25 @@ class TestContentMixins(TestCase): self.assertEqual(view.RAW_CONTENT, content) def test_standard_behaviour_determines_no_content_GET(self): - """Ensure request.RAW_CONTENT returns None for GET request with no content.""" + """Ensure view.RAW_CONTENT returns None for GET request with no content.""" self.ensure_determines_no_content_GET(RequestMixin()) def test_standard_behaviour_determines_form_content_POST(self): - """Ensure request.RAW_CONTENT returns content for POST request with form content.""" + """Ensure view.RAW_CONTENT returns content for POST request with form content.""" self.ensure_determines_form_content_POST(RequestMixin()) def test_standard_behaviour_determines_non_form_content_POST(self): - """Ensure StandardContentMixin.determine_content(request) returns (content type, content) for POST request with content.""" + """Ensure view.RAW_CONTENT returns content for POST request with non-form content.""" self.ensure_determines_non_form_content_POST(RequestMixin()) def test_standard_behaviour_determines_form_content_PUT(self): - """Ensure StandardContentMixin.determine_content(request) returns content for PUT request with content.""" + """Ensure view.RAW_CONTENT returns content for PUT request with form content.""" self.ensure_determines_form_content_PUT(RequestMixin()) -# 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()) -# + def test_standard_behaviour_determines_non_form_content_PUT(self): + """Ensure view.RAW_CONTENT returns content for PUT request with non-form content.""" + self.ensure_determines_non_form_content_PUT(RequestMixin()) + # # OverloadedContentMixin behavioural tests # # def test_overloaded_behaviour_determines_no_content_GET(self): @@ -108,16 +108,18 @@ class TestContentMixins(TestCase): # """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.CONTENT_PARAM: content, -# OverloadedContentMixin.CONTENTTYPE_PARAM: content_type} -# request = self.req.post('/', form_data) -# self.assertEqual(OverloadedContentMixin().determine_content(request), (content_type, content)) -# self.assertEqual(request.META['CONTENT_TYPE'], content_type) -# + def test_overloaded_behaviour_allows_content_tunnelling(self): + """Ensure request.RAW_CONTENT returns content for overloaded POST request""" + content = 'qwerty' + content_type = 'text/plain' + view = RequestMixin() + form_data = {view.CONTENT_PARAM: content, + view.CONTENTTYPE_PARAM: content_type} + view.request = self.req.post('/', form_data) + view.parsers = (PlainTextParser,) + view.perform_form_overloading() + self.assertEqual(view.RAW_CONTENT, 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' From dad1fa5798912e460ff05283c1c3b37d37b8f007 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 11 Apr 2011 13:13:25 +0100 Subject: [PATCH 07/70] method overloading tests passing --- djangorestframework/tests/content.py | 10 +++--- djangorestframework/tests/methods.py | 53 +++++++++++++--------------- 2 files changed, 28 insertions(+), 35 deletions(-) diff --git a/djangorestframework/tests/content.py b/djangorestframework/tests/content.py index 05679b2f3..240601e0a 100644 --- a/djangorestframework/tests/content.py +++ b/djangorestframework/tests/content.py @@ -1,12 +1,11 @@ -# TODO: refactor these tests +# TODO: finish off the refactoring from django.test import TestCase from djangorestframework.compat import RequestFactory from djangorestframework.request import RequestMixin from djangorestframework.parsers import FormParser, MultipartParser, PlainTextParser -#from djangorestframework.content import ContentMixin, StandardContentMixin, OverloadedContentMixin -# -# -class TestContentMixins(TestCase): + + +class TestContentParsing(TestCase): def setUp(self): self.req = RequestFactory() @@ -125,4 +124,3 @@ class TestContentMixins(TestCase): # content = 'qwerty' # 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 f19bb3e5a..8e6940178 100644 --- a/djangorestframework/tests/methods.py +++ b/djangorestframework/tests/methods.py @@ -1,12 +1,13 @@ # TODO: Refactor these tests -#from django.test import TestCase -#from djangorestframework.compat import RequestFactory +from django.test import TestCase +from djangorestframework.compat import RequestFactory +from djangorestframework.request import RequestMixin #from djangorestframework.methods import MethodMixin, StandardMethodMixin, OverloadedPOSTMethodMixin # # -#class TestMethodMixins(TestCase): -# def setUp(self): -# self.req = RequestFactory() +class TestMethodOverloading(TestCase): + def setUp(self): + self.req = RequestFactory() # # # Interface tests # @@ -27,27 +28,21 @@ # # # 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.METHOD_PARAM: 'DELETE'}) -# self.assertEqual(OverloadedPOSTMethodMixin().determine_method(request), 'DELETE') + def test_standard_behaviour_determines_GET(self): + """GET requests identified""" + view = RequestMixin() + view.request = self.req.get('/') + self.assertEqual(view.method, 'GET') + + def test_standard_behaviour_determines_POST(self): + """POST requests identified""" + view = RequestMixin() + view.request = self.req.post('/') + self.assertEqual(view.method, 'POST') + + def test_overloaded_POST_behaviour_determines_overloaded_method(self): + """POST requests can be overloaded to another method by setting a reserved form field""" + view = RequestMixin() + view.request = self.req.post('/', {view.METHOD_PARAM: 'DELETE'}) + view.perform_form_overloading() + self.assertEqual(view.method, 'DELETE') From 92b5a455da87c1d71d81903ea666daafb6b79725 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 11 Apr 2011 13:14:51 +0100 Subject: [PATCH 08/70] Test cleanup --- djangorestframework/tests/methods.py | 27 +++------------------------ 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/djangorestframework/tests/methods.py b/djangorestframework/tests/methods.py index 8e6940178..7f6acf4f8 100644 --- a/djangorestframework/tests/methods.py +++ b/djangorestframework/tests/methods.py @@ -1,33 +1,12 @@ -# TODO: Refactor these tests from django.test import TestCase from djangorestframework.compat import RequestFactory from djangorestframework.request import RequestMixin -#from djangorestframework.methods import MethodMixin, StandardMethodMixin, OverloadedPOSTMethodMixin -# -# + + class TestMethodOverloading(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, 'METHOD_PARAM') -# getattr(OverloadedPOSTMethodMixin, 'determine_method') -# -# # Behavioural tests -# + def test_standard_behaviour_determines_GET(self): """GET requests identified""" view = RequestMixin() From 941742593c50dcb0e1ca426621d107f12c9ee65c Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 11 Apr 2011 13:22:54 +0100 Subject: [PATCH 09/70] Remove last bits of ParserMixin --- djangorestframework/parsers.py | 4 --- djangorestframework/request.py | 2 +- djangorestframework/resource.py | 3 +- djangorestframework/tests/content.py | 53 ++-------------------------- 4 files changed, 5 insertions(+), 57 deletions(-) diff --git a/djangorestframework/parsers.py b/djangorestframework/parsers.py index 5b2366475..11adeb78a 100644 --- a/djangorestframework/parsers.py +++ b/djangorestframework/parsers.py @@ -24,10 +24,6 @@ try: except ImportError: from cgi import parse_qs -class ParserMixin(object): - parsers = () - - class BaseParser(object): diff --git a/djangorestframework/request.py b/djangorestframework/request.py index 988c0592f..71ff8c0b7 100644 --- a/djangorestframework/request.py +++ b/djangorestframework/request.py @@ -9,7 +9,7 @@ from django.http.multipartparser import LimitBytes from StringIO import StringIO class RequestMixin(object): - """Delegate class that supplements an HttpRequest object with additional behaviour.""" + """Mixin behaviour to deal with requests.""" USE_FORM_OVERLOADING = True METHOD_PARAM = "_method" diff --git a/djangorestframework/resource.py b/djangorestframework/resource.py index 80e5df2a2..6ec220734 100644 --- a/djangorestframework/resource.py +++ b/djangorestframework/resource.py @@ -3,7 +3,6 @@ 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.response import Response, ResponseException @@ -19,7 +18,7 @@ from djangorestframework import emitters, parsers, authenticators, status __all__ = ['Resource'] -class Resource(EmitterMixin, ParserMixin, AuthenticatorMixin, FormValidatorMixin, RequestMixin, View): +class Resource(EmitterMixin, AuthenticatorMixin, FormValidatorMixin, RequestMixin, View): """Handles incoming requests and maps them to REST operations, performing authentication, input deserialization, input validation, output serialization.""" diff --git a/djangorestframework/tests/content.py b/djangorestframework/tests/content.py index 240601e0a..b99f30f71 100644 --- a/djangorestframework/tests/content.py +++ b/djangorestframework/tests/content.py @@ -1,4 +1,6 @@ -# TODO: finish off the refactoring +""" +Tests for content parsing, and form-overloaded content parsing. +""" from django.test import TestCase from djangorestframework.compat import RequestFactory from djangorestframework.request import RequestMixin @@ -9,27 +11,6 @@ class TestContentParsing(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, 'CONTENT_PARAM') -# getattr(OverloadedContentMixin, 'CONTENTTYPE_PARAM') -# getattr(OverloadedContentMixin, 'determine_content') -# -# -# # Common functionality to test with both StandardContentMixin and OverloadedContentMixin -# def ensure_determines_no_content_GET(self, view): """Ensure view.RAW_CONTENT returns None for GET request with no content.""" view.request = self.req.get('/') @@ -85,28 +66,6 @@ class TestContentParsing(TestCase): """Ensure view.RAW_CONTENT returns content for PUT request with non-form content.""" self.ensure_determines_non_form_content_PUT(RequestMixin()) -# # 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 request.RAW_CONTENT returns content for overloaded POST request""" content = 'qwerty' @@ -118,9 +77,3 @@ class TestContentParsing(TestCase): view.parsers = (PlainTextParser,) view.perform_form_overloading() self.assertEqual(view.RAW_CONTENT, 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.CONTENT_PARAM: content}) -# self.assertEqual(OverloadedContentMixin().determine_content(request), (None, content)) From cb9fb6ef2f9ac38c4f1c3946252a542b1f3f15d7 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 11 Apr 2011 13:45:38 +0100 Subject: [PATCH 10/70] Refactoring of authentication/permissions --- djangorestframework/authenticators.py | 20 ------------- djangorestframework/request.py | 41 ++++++++++++++++++++++++++- djangorestframework/resource.py | 10 +++++-- djangorestframework/response.py | 6 ++-- 4 files changed, 50 insertions(+), 27 deletions(-) diff --git a/djangorestframework/authenticators.py b/djangorestframework/authenticators.py index 0d267b648..e382de10a 100644 --- a/djangorestframework/authenticators.py +++ b/djangorestframework/authenticators.py @@ -10,26 +10,6 @@ from djangorestframework.utils import as_tuple import base64 -class AuthenticatorMixin(object): - """Adds pluggable authentication behaviour.""" - - """The set of authenticators to use.""" - 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 simply be a :class:`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.""" diff --git a/djangorestframework/request.py b/djangorestframework/request.py index 71ff8c0b7..8a4330b44 100644 --- a/djangorestframework/request.py +++ b/djangorestframework/request.py @@ -9,7 +9,7 @@ from django.http.multipartparser import LimitBytes from StringIO import StringIO class RequestMixin(object): - """Mixin behaviour to deal with requests.""" + """Mixin class to provide request parsing behaviour.""" USE_FORM_OVERLOADING = True METHOD_PARAM = "_method" @@ -214,3 +214,42 @@ class RequestMixin(object): +class AuthMixin(object): + """Mixin class to provide authentication and permissions.""" + authenticators = () + permitters = () + + @property + def auth(self): + if not hasattr(self, '_auth'): + self._auth = self._authenticate() + return self._auth + + # TODO? + #@property + #def user(self): + # if not has_attr(self, '_user'): + # auth = self.auth + # if isinstance(auth, User...): + # self._user = auth + # else: + # self._user = getattr(auth, 'user', None) + # return self._user + + def check_permissions(self): + if not self.permissions: + return + + auth = self.auth + for permitter_cls in self.permitters: + permitter = permission_cls(self) + permitter.permit(auth) + + def _authenticate(self): + for authenticator_cls in self.authenticators: + authenticator = authenticator_cls(self) + auth = authenticator.authenticate(self.request) + if auth: + return auth + return None + diff --git a/djangorestframework/resource.py b/djangorestframework/resource.py index 6ec220734..e62903635 100644 --- a/djangorestframework/resource.py +++ b/djangorestframework/resource.py @@ -6,7 +6,7 @@ from djangorestframework.emitters import EmitterMixin from djangorestframework.authenticators import AuthenticatorMixin from djangorestframework.validators import FormValidatorMixin from djangorestframework.response import Response, ResponseException -from djangorestframework.request import RequestMixin +from djangorestframework.request import RequestMixin, AuthMixin from djangorestframework import emitters, parsers, authenticators, status @@ -18,7 +18,7 @@ from djangorestframework import emitters, parsers, authenticators, status __all__ = ['Resource'] -class Resource(EmitterMixin, AuthenticatorMixin, FormValidatorMixin, RequestMixin, View): +class Resource(EmitterMixin, AuthMixin, FormValidatorMixin, RequestMixin, View): """Handles incoming requests and maps them to REST operations, performing authentication, input deserialization, input validation, output serialization.""" @@ -139,7 +139,7 @@ class Resource(EmitterMixin, AuthenticatorMixin, FormValidatorMixin, RequestMixi # Typically the context will be a user, or None if this is an anonymous request, # but it could potentially be more complex (eg the context of a request key which # has been signed against a particular set of permissions) - auth_context = self.authenticate(request) + auth_context = self.auth # If using a form POST with '_method'/'_content'/'_content_type' overrides, then alter # self.method, self.content_type, self.CONTENT appropriately. @@ -173,6 +173,10 @@ class Resource(EmitterMixin, AuthenticatorMixin, FormValidatorMixin, RequestMixi except ResponseException, exc: response = exc.response + + except: + import traceback + traceback.print_exc() # Always add these headers. # diff --git a/djangorestframework/response.py b/djangorestframework/response.py index fb2e14a28..809e17546 100644 --- a/djangorestframework/response.py +++ b/djangorestframework/response.py @@ -8,7 +8,7 @@ class NoContent(object): """Used to indicate no body in http response. (We cannot just use None, as that is a valid, serializable response object.) - TODO: On relflection I'm going to get rid of this and just not support serailized 'None' responses. + TODO: On reflection I'm going to get rid of this and just not support serialized 'None' responses. """ pass @@ -23,8 +23,8 @@ class Response(object): @property def status_text(self): - """Return reason text corrosponding to our HTTP response status code. - Provided for convienience.""" + """Return reason text corresponding to our HTTP response status code. + Provided for convenience.""" return STATUS_CODE_TEXT.get(self.status, '') From 136c9b5271fc205abffbbe5422b3c345858a533b Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 11 Apr 2011 13:52:16 +0100 Subject: [PATCH 11/70] Tweak --- djangorestframework/request.py | 2 ++ djangorestframework/resource.py | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/djangorestframework/request.py b/djangorestframework/request.py index 8a4330b44..7f2cb0bc3 100644 --- a/djangorestframework/request.py +++ b/djangorestframework/request.py @@ -17,6 +17,7 @@ class RequestMixin(object): CONTENT_PARAM = "_content" parsers = () + validators = () def _get_method(self): """ @@ -169,6 +170,7 @@ class RequestMixin(object): self._stream = StringIO(content[self.CONTENT_PARAM]) del(self._raw_content) + def parse(self, stream, content_type): """ Parse the request content. diff --git a/djangorestframework/resource.py b/djangorestframework/resource.py index e62903635..c5faf1750 100644 --- a/djangorestframework/resource.py +++ b/djangorestframework/resource.py @@ -3,7 +3,6 @@ from django.views.decorators.csrf import csrf_exempt from djangorestframework.compat import View from djangorestframework.emitters import EmitterMixin -from djangorestframework.authenticators import AuthenticatorMixin from djangorestframework.validators import FormValidatorMixin from djangorestframework.response import Response, ResponseException from djangorestframework.request import RequestMixin, AuthMixin From a9df917d10e5f84090074e11213eb6d550c174cc Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 11 Apr 2011 15:03:49 +0100 Subject: [PATCH 12/70] Lots of validator tests passing after refactor --- djangorestframework/emitters.py | 23 +-- djangorestframework/modelresource.py | 8 +- djangorestframework/request.py | 23 +++ djangorestframework/resource.py | 10 +- djangorestframework/tests/validators.py | 254 +++++++++++++----------- djangorestframework/validators.py | 93 +++++---- 6 files changed, 242 insertions(+), 169 deletions(-) diff --git a/djangorestframework/emitters.py b/djangorestframework/emitters.py index 2769a4f55..36feea05d 100644 --- a/djangorestframework/emitters.py +++ b/djangorestframework/emitters.py @@ -9,7 +9,6 @@ from django.template import RequestContext, loader from django import forms from djangorestframework.response import NoContent, ResponseException -from djangorestframework.validators import FormValidatorMixin from djangorestframework.utils import dict2xml, url_resolves from djangorestframework.markdownwrapper import apply_markdown from djangorestframework.breadcrumbs import get_breadcrumbs @@ -217,15 +216,11 @@ class DocumentingTemplateEmitter(BaseEmitter): #form_instance = resource.form_instance # TODO! Reinstate this - form_instance = None + form_instance = getattr(resource, 'bound_form_instance', None) - 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 - + if not form_instance and hasattr(resource, 'get_bound_form'): # 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: + if resource.response.has_content_body: try: form_instance = resource.get_bound_form(resource.response.cleaned_content) if form_instance and not form_instance.is_valid(): @@ -233,12 +228,12 @@ class DocumentingTemplateEmitter(BaseEmitter): 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 = resource.get_bound_form() - except: - pass + # If we still don't have a form instance then try to get an unbound form + if not form_instance: + try: + form_instance = 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/modelresource.py b/djangorestframework/modelresource.py index 55a15d6af..7fa370532 100644 --- a/djangorestframework/modelresource.py +++ b/djangorestframework/modelresource.py @@ -5,15 +5,14 @@ from django.db.models.fields.related import RelatedField from djangorestframework.response import Response, ResponseException from djangorestframework.resource import Resource -from djangorestframework.validators import ModelFormValidatorMixin -from djangorestframework import status +from djangorestframework import status, validators import decimal import inspect import re -class ModelResource(Resource, ModelFormValidatorMixin): +class ModelResource(Resource): """A specialized type of Resource, for resources that map directly to a Django Model. Useful things this provides: @@ -21,6 +20,9 @@ class ModelResource(Resource, ModelFormValidatorMixin): 1. Nice serialization of returned Models and QuerySets. 2. A default set of create/read/update/delete operations.""" + # List of validators to validate, cleanup and type-ify the request content + validators = (validators.ModelFormValidator,) + # The model attribute refers to the Django Model which this Resource maps to. # (The Model's class, rather than an instance of the Model) model = None diff --git a/djangorestframework/request.py b/djangorestframework/request.py index 7f2cb0bc3..33d6bb2fc 100644 --- a/djangorestframework/request.py +++ b/djangorestframework/request.py @@ -196,6 +196,29 @@ class RequestMixin(object): return parser.parse(stream) + + def validate(self, content): + """ + Validate, cleanup, and type-ify the request content. + """ + for validator_cls in self.validators: + validator = validator_cls(self) + content = validator.validate(content) + return content + + + def get_bound_form(self, content=None): + """ + Return a bound form instance for the given content, + if there is an appropriate form validator attached to the view. + """ + for validator_cls in self.validators: + if hasattr(validator_cls, 'get_bound_form'): + validator = validator_cls(self) + return validator.get_bound_form(content) + return None + + @property def parsed_media_types(self): """Return an list of all the media types that this view can parse.""" diff --git a/djangorestframework/resource.py b/djangorestframework/resource.py index c5faf1750..02c55663d 100644 --- a/djangorestframework/resource.py +++ b/djangorestframework/resource.py @@ -3,10 +3,9 @@ from django.views.decorators.csrf import csrf_exempt from djangorestframework.compat import View from djangorestframework.emitters import EmitterMixin -from djangorestframework.validators import FormValidatorMixin from djangorestframework.response import Response, ResponseException from djangorestframework.request import RequestMixin, AuthMixin -from djangorestframework import emitters, parsers, authenticators, status +from djangorestframework import emitters, parsers, authenticators, validators, status # TODO: Figure how out references and named urls need to work nicely @@ -17,7 +16,7 @@ from djangorestframework import emitters, parsers, authenticators, status __all__ = ['Resource'] -class Resource(EmitterMixin, AuthMixin, FormValidatorMixin, RequestMixin, View): +class Resource(EmitterMixin, AuthMixin, RequestMixin, View): """Handles incoming requests and maps them to REST operations, performing authentication, input deserialization, input validation, output serialization.""" @@ -38,7 +37,10 @@ class Resource(EmitterMixin, AuthMixin, FormValidatorMixin, RequestMixin, View): parsers = ( parsers.JSONParser, parsers.FormParser, parsers.MultipartParser ) - + + # List of validators to validate, cleanup and type-ify the request content + validators = (validators.FormValidator,) + # List of all authenticating methods to attempt. authenticators = ( authenticators.UserLoggedInAuthenticator, authenticators.BasicAuthenticator ) diff --git a/djangorestframework/tests/validators.py b/djangorestframework/tests/validators.py index b5d2d566c..f7d2d5295 100644 --- a/djangorestframework/tests/validators.py +++ b/djangorestframework/tests/validators.py @@ -2,7 +2,7 @@ from django import forms from django.db import models from django.test import TestCase from djangorestframework.compat import RequestFactory -from djangorestframework.validators import ValidatorMixin, FormValidatorMixin, ModelFormValidatorMixin +from djangorestframework.validators import BaseValidator, FormValidator, ModelFormValidator from djangorestframework.response import ResponseException @@ -11,59 +11,68 @@ class TestValidatorMixinInterfaces(TestCase): def test_validator_mixin_interface(self): """Ensure the ValidatorMixin base class interface is as expected.""" - self.assertRaises(NotImplementedError, ValidatorMixin().validate, None) + self.assertRaises(NotImplementedError, BaseValidator(None).validate, None) - def test_form_validator_mixin_interface(self): - """Ensure the FormValidatorMixin interface is as expected.""" - self.assertTrue(issubclass(FormValidatorMixin, ValidatorMixin)) - getattr(FormValidatorMixin, 'form') - getattr(FormValidatorMixin, 'validate') + #def test_form_validator_mixin_interface(self): + # """Ensure the FormValidatorMixin interface is as expected.""" + # self.assertTrue(issubclass(FormValidator, BaseValidator)) + # getattr(FormValidator, 'form') + # getattr(FormValidator, 'validate') - def test_model_form_validator_mixin_interface(self): - """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') + #def test_model_form_validator_mixin_interface(self): + # """Ensure the ModelFormValidatorMixin interface is as expected.""" + # self.assertTrue(issubclass(ModelFormValidator, FormValidator)) + # getattr(ModelFormValidator, 'model') + # getattr(ModelFormValidator, 'form') + # getattr(ModelFormValidator, 'fields') + # getattr(ModelFormValidator, 'exclude_fields') + # getattr(ModelFormValidator, 'validate') class TestDisabledValidations(TestCase): - """Tests on Validator Mixins with validation disabled by setting form to None""" + """Tests on FormValidator 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): + """If the view's form attribute is None then FormValidator(view).validate(content) + should just return the content unmodified.""" + class DisabledFormView(object): form = None + view = DisabledFormView() content = {'qwerty':'uiop'} - self.assertEqual(DisabledFormValidator().validate(content), content) + self.assertEqual(FormValidator(view).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 - - content = {'qwerty':'uiop'} - self.assertEqual(DisabledFormValidator().get_bound_form(content), None) - - 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): + """If the view's form attribute is None on then + FormValidator(view).get_bound_form(content) should just return None.""" + class DisabledFormView(object): form = None + view = DisabledFormView() content = {'qwerty':'uiop'} - self.assertEqual(DisabledModelFormValidator().validate(content), content) + self.assertEqual(FormValidator(view).get_bound_form(content), None) + + + def test_disabled_model_form_validator_returns_content_unchanged(self): + """If the view's form and model attributes are None then + ModelFormValidator(view).validate(content) should just return the content unmodified.""" + class DisabledModelFormView(object): + form = None + model = None + + view = DisabledModelFormView() + content = {'qwerty':'uiop'} + self.assertEqual(ModelFormValidator(view).get_bound_form(content), None)# 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): + class DisabledModelFormView(object): form = None - - content = {'qwerty':'uiop'} - self.assertEqual(DisabledModelFormValidator().get_bound_form(content), None) - + model = None + + view = DisabledModelFormView() + content = {'qwerty':'uiop'} + self.assertEqual(ModelFormValidator(view).get_bound_form(content), None)# class TestNonFieldErrors(TestCase): """Tests against form validation errors caused by non-field errors. (eg as might be caused by some custom form validation)""" @@ -80,12 +89,13 @@ class TestNonFieldErrors(TestCase): raise forms.ValidationError(self.ERROR_TEXT) return self.cleaned_data #pragma: no cover - class MockValidator(FormValidatorMixin): + class MockView(object): form = MockForm + view = MockView() content = {'field1': 'example1', 'field2': 'example2'} try: - MockValidator().validate(content) + FormValidator(view).validate(content) except ResponseException, exc: self.assertEqual(exc.response.raw_content, {'errors': [MockForm.ERROR_TEXT]}) else: @@ -95,19 +105,21 @@ class TestNonFieldErrors(TestCase): 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)""" + (ModelFormValidator should behave as FormValidator if a 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): + class MockFormView(object): form = MockForm - - class MockModelFormValidator(ModelFormValidatorMixin): + validators = (FormValidator,) + + class MockModelFormView(object): form = MockForm - - self.MockFormValidator = MockFormValidator - self.MockModelFormValidator = MockModelFormValidator + validators = (ModelFormValidator,) + + self.MockFormView = MockFormView + self.MockModelFormView = MockModelFormView def validation_returns_content_unchanged_if_already_valid_and_clean(self, validator): @@ -181,111 +193,129 @@ class TestFormValidation(TestCase): # 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()) + validator = FormValidator(self.MockFormView()) + self.validation_returns_content_unchanged_if_already_valid_and_clean(validator) def test_form_validation_failure_raises_response_exception(self): - self.validation_failure_raises_response_exception(self.MockFormValidator()) + validator = FormValidator(self.MockFormView()) + self.validation_failure_raises_response_exception(validator) def test_validation_does_not_allow_extra_fields_by_default(self): - self.validation_does_not_allow_extra_fields_by_default(self.MockFormValidator()) + validator = FormValidator(self.MockFormView()) + self.validation_does_not_allow_extra_fields_by_default(validator) def test_validation_allows_extra_fields_if_explicitly_set(self): - self.validation_allows_extra_fields_if_explicitly_set(self.MockFormValidator()) + validator = FormValidator(self.MockFormView()) + self.validation_allows_extra_fields_if_explicitly_set(validator) def test_validation_does_not_require_extra_fields_if_explicitly_set(self): - self.validation_does_not_require_extra_fields_if_explicitly_set(self.MockFormValidator()) + validator = FormValidator(self.MockFormView()) + self.validation_does_not_require_extra_fields_if_explicitly_set(validator) def test_validation_failed_due_to_no_content_returns_appropriate_message(self): - self.validation_failed_due_to_no_content_returns_appropriate_message(self.MockFormValidator()) + validator = FormValidator(self.MockFormView()) + self.validation_failed_due_to_no_content_returns_appropriate_message(validator) def test_validation_failed_due_to_field_error_returns_appropriate_message(self): - self.validation_failed_due_to_field_error_returns_appropriate_message(self.MockFormValidator()) + validator = FormValidator(self.MockFormView()) + self.validation_failed_due_to_field_error_returns_appropriate_message(validator) def test_validation_failed_due_to_invalid_field_returns_appropriate_message(self): - self.validation_failed_due_to_invalid_field_returns_appropriate_message(self.MockFormValidator()) + validator = FormValidator(self.MockFormView()) + self.validation_failed_due_to_invalid_field_returns_appropriate_message(validator) def test_validation_failed_due_to_multiple_errors_returns_appropriate_message(self): - self.validation_failed_due_to_multiple_errors_returns_appropriate_message(self.MockFormValidator()) + validator = FormValidator(self.MockFormView()) + self.validation_failed_due_to_multiple_errors_returns_appropriate_message(validator) # 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()) + validator = ModelFormValidator(self.MockModelFormView()) + self.validation_returns_content_unchanged_if_already_valid_and_clean(validator) def test_modelform_validation_failure_raises_response_exception(self): - self.validation_failure_raises_response_exception(self.MockModelFormValidator()) + validator = ModelFormValidator(self.MockModelFormView()) + self.validation_failure_raises_response_exception(validator) def test_modelform_validation_does_not_allow_extra_fields_by_default(self): - self.validation_does_not_allow_extra_fields_by_default(self.MockModelFormValidator()) + validator = ModelFormValidator(self.MockModelFormView()) + self.validation_does_not_allow_extra_fields_by_default(validator) def test_modelform_validation_allows_extra_fields_if_explicitly_set(self): - self.validation_allows_extra_fields_if_explicitly_set(self.MockModelFormValidator()) + validator = ModelFormValidator(self.MockModelFormView()) + self.validation_allows_extra_fields_if_explicitly_set(validator) 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()) + validator = ModelFormValidator(self.MockModelFormView()) + self.validation_does_not_require_extra_fields_if_explicitly_set(validator) 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()) + validator = ModelFormValidator(self.MockModelFormView()) + self.validation_failed_due_to_no_content_returns_appropriate_message(validator) 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()) + validator = ModelFormValidator(self.MockModelFormView()) + self.validation_failed_due_to_field_error_returns_appropriate_message(validator) 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()) + validator = ModelFormValidator(self.MockModelFormView()) + self.validation_failed_due_to_invalid_field_returns_appropriate_message(validator) 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()) + validator = ModelFormValidator(self.MockModelFormView()) + self.validation_failed_due_to_multiple_errors_returns_appropriate_message(validator) -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)) +# 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)) diff --git a/djangorestframework/validators.py b/djangorestframework/validators.py index d96e8d9ef..609e6d366 100644 --- a/djangorestframework/validators.py +++ b/djangorestframework/validators.py @@ -4,25 +4,31 @@ 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.""" + +class BaseValidator(object): + """Base class for all Validator classes, which simply defines the interface they provide.""" + + def __init__(self, view): + self.view = view 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. - + Typically 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.)""" +class FormValidator(BaseValidator): + """Validator class that uses forms for validation. + Also provides a get_bound_form() method which may be used by some renderers. - """The form class that should be used for validation, or None to turn off form validation.""" - form = None - bound_form_instance = None + The view class should provide `.form` attribute which specifies the form classmethod + to be used for validation. + + On calling validate() this validator may set a `.bound_form_instance` attribute on the + view, which may be used by some renderers.""" + def validate(self, content): """Given some content as input return some cleaned, validated content. @@ -44,7 +50,7 @@ class FormValidatorMixin(ValidatorMixin): if bound_form is None: return content - self.bound_form_instance = bound_form + self.view.bound_form_instance = bound_form seen_fields_set = set(content.keys()) form_fields_set = set(bound_form.fields.keys()) @@ -78,7 +84,10 @@ class FormValidatorMixin(ValidatorMixin): 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('__')) + 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: @@ -94,20 +103,21 @@ class FormValidatorMixin(ValidatorMixin): 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: + form_cls = getattr(self.view, 'form', None) + + if not form_cls: return None - if not content is None: + if content is not None: if hasattr(content, 'FILES'): - return self.form(content, content.FILES) - return self.form(content) - return self.form() + return form_cls(content, content.FILES) + return form_cls(content) + return form_cls() -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.)""" +class ModelFormValidator(FormValidator): + """Validator class that uses forms for validation and otherwise falls back to a model form if no form is set. + Also provides a get_bound_form() method which may be used by some renderers.""" """The form class that should be used for validation, or None to use model form validation.""" form = None @@ -148,15 +158,18 @@ 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) + form_cls = getattr(self.view, 'form', None) + model_cls = getattr(self.view, 'model', None) - elif self.model: + if form_cls: + # Use explict Form + return super(ModelFormValidator, self).get_bound_form(content) + + elif model_cls: # Fall back to ModelForm which we create on the fly class OnTheFlyModelForm(forms.ModelForm): class Meta: - model = self.model + model = model_cls #fields = tuple(self._model_fields_set) # Instantiate the ModelForm as appropriate @@ -176,24 +189,32 @@ class ModelFormValidatorMixin(FormValidatorMixin): @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) + model = getattr(self.view, 'model', None) + fields = getattr(self.view, 'fields', self.fields) + exclude_fields = getattr(self.view, 'exclude_fields', self.exclude_fields) - if self.fields: - return model_fields & set(as_tuple(self.fields)) + model_fields = set(field.name for field in model._meta.fields) - return model_fields - set(as_tuple(self.exclude_fields)) + if fields: + return model_fields & set(as_tuple(fields)) + + return model_fields - set(as_tuple(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) + model = getattr(self.view, 'model', None) + fields = getattr(self.view, 'fields', self.fields) + exclude_fields = getattr(self.view, 'exclude_fields', self.exclude_fields) + + property_fields = set(attr for attr in dir(model) if + isinstance(getattr(model, attr, None), property) and not attr.startswith('_')) - if self.fields: - return property_fields & set(as_tuple(self.fields)) + if fields: + return property_fields & set(as_tuple(fields)) - return property_fields - set(as_tuple(self.exclude_fields)) + return property_fields - set(as_tuple(exclude_fields)) From 29db0a60fb88686f721da75dc058222c922ffdeb Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 11 Apr 2011 15:06:29 +0100 Subject: [PATCH 13/70] Even more validator tests passing after refactor --- djangorestframework/tests/validators.py | 100 ++++++++++++------------ 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/djangorestframework/tests/validators.py b/djangorestframework/tests/validators.py index f7d2d5295..1f14f7108 100644 --- a/djangorestframework/tests/validators.py +++ b/djangorestframework/tests/validators.py @@ -267,55 +267,55 @@ class TestFormValidation(TestCase): self.validation_failed_due_to_multiple_errors_returns_appropriate_message(validator) -# 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)) +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 MockView(object): + model = MockModel + + self.validator = ModelFormValidator(MockView) + + + 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.validator.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.validator.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.validator.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.validator.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.validator.validate(content) + + def test_model_form_validator_uses_model_forms(self): + self.assertTrue(isinstance(self.validator.get_bound_form(), forms.ModelForm)) From a1ed565081779e3f50e9f0ff280a813a46f3613d Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 11 Apr 2011 15:09:52 +0100 Subject: [PATCH 14/70] Cleanup --- djangorestframework/resource.py | 2 +- djangorestframework/tests/validators.py | 15 --------------- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/djangorestframework/resource.py b/djangorestframework/resource.py index 02c55663d..1a02f8b58 100644 --- a/djangorestframework/resource.py +++ b/djangorestframework/resource.py @@ -39,7 +39,7 @@ class Resource(EmitterMixin, AuthMixin, RequestMixin, View): parsers.MultipartParser ) # List of validators to validate, cleanup and type-ify the request content - validators = (validators.FormValidator,) + validators = ( validators.FormValidator, ) # List of all authenticating methods to attempt. authenticators = ( authenticators.UserLoggedInAuthenticator, diff --git a/djangorestframework/tests/validators.py b/djangorestframework/tests/validators.py index 1f14f7108..a091cf29e 100644 --- a/djangorestframework/tests/validators.py +++ b/djangorestframework/tests/validators.py @@ -13,21 +13,6 @@ class TestValidatorMixinInterfaces(TestCase): """Ensure the ValidatorMixin base class interface is as expected.""" self.assertRaises(NotImplementedError, BaseValidator(None).validate, None) - #def test_form_validator_mixin_interface(self): - # """Ensure the FormValidatorMixin interface is as expected.""" - # self.assertTrue(issubclass(FormValidator, BaseValidator)) - # getattr(FormValidator, 'form') - # getattr(FormValidator, 'validate') - - #def test_model_form_validator_mixin_interface(self): - # """Ensure the ModelFormValidatorMixin interface is as expected.""" - # self.assertTrue(issubclass(ModelFormValidator, FormValidator)) - # getattr(ModelFormValidator, 'model') - # getattr(ModelFormValidator, 'form') - # getattr(ModelFormValidator, 'fields') - # getattr(ModelFormValidator, 'exclude_fields') - # getattr(ModelFormValidator, 'validate') - class TestDisabledValidations(TestCase): """Tests on FormValidator with validation disabled by setting form to None""" From 349ffcaf5f03b55d8ffe92999814ba97da5ca870 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 11 Apr 2011 16:38:00 +0100 Subject: [PATCH 15/70] Rename mixins into Mixin class, rename ResponseException to ErrorResponse, remove NoContent --- djangorestframework/emitters.py | 147 ++---------------------- djangorestframework/modelresource.py | 6 +- djangorestframework/parsers.py | 4 +- djangorestframework/resource.py | 27 +++-- djangorestframework/response.py | 27 ++--- djangorestframework/tests/content.py | 2 +- djangorestframework/tests/emitters.py | 5 +- djangorestframework/tests/methods.py | 2 +- djangorestframework/tests/validators.py | 20 ++-- djangorestframework/utils.py | 1 + djangorestframework/validators.py | 14 +-- 11 files changed, 64 insertions(+), 191 deletions(-) diff --git a/djangorestframework/emitters.py b/djangorestframework/emitters.py index 36feea05d..2990d313e 100644 --- a/djangorestframework/emitters.py +++ b/djangorestframework/emitters.py @@ -4,11 +4,10 @@ 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, ResponseException +from djangorestframework.response import ErrorResponse from djangorestframework.utils import dict2xml, url_resolves from djangorestframework.markdownwrapper import apply_markdown from djangorestframework.breadcrumbs import get_breadcrumbs @@ -18,7 +17,6 @@ from djangorestframework import status from urllib import quote_plus import string import re -from decimal import Decimal try: import json @@ -26,132 +24,9 @@ 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): - """Adds behaviour for pluggable Emitters to a :class:`.Resource` or Django :class:`View`. class. - - Default behaviour is to use standard HTTP Accept header content negotiation. - Also supports overidding the content type by specifying an _accept= parameter in the URL. - Ignores Accept headers from Internet Explorer user agents and uses a sensible browser Accept header instead.""" - - 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): - """Takes a :class:`Response` object and returns a Django :class:`HttpResponse`.""" - 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, +# TODO: Maybe None could be handled more cleanly. It'd be nice if it was handled by default, # and only have an emitter output anything if it explicitly provides support for that. class BaseEmitter(object): @@ -162,10 +37,10 @@ class BaseEmitter(object): def __init__(self, resource): self.resource = resource - def emit(self, output=NoContent, verbose=False): + def emit(self, output=None, verbose=False): """By default emit simply returns the ouput as-is. Override this method to provide for other behaviour.""" - if output is NoContent: + if output is None: return '' return output @@ -177,8 +52,8 @@ class TemplateEmitter(BaseEmitter): media_type = None template = None - def emit(self, output=NoContent, verbose=False): - if output is NoContent: + def emit(self, output=None, verbose=False): + if output is None: return '' context = RequestContext(self.request, output) @@ -276,7 +151,7 @@ class DocumentingTemplateEmitter(BaseEmitter): return GenericContentForm(resource) - def emit(self, output=NoContent): + def emit(self, output=None): content = self._get_content(self.resource, self.resource.request, output) form_instance = self._get_form_instance(self.resource) @@ -324,8 +199,8 @@ class JSONEmitter(BaseEmitter): """Emitter which serializes to JSON""" media_type = 'application/json' - def emit(self, output=NoContent, verbose=False): - if output is NoContent: + def emit(self, output=None, verbose=False): + if output is None: return '' if verbose: return json.dumps(output, indent=4, sort_keys=True) @@ -336,8 +211,8 @@ class XMLEmitter(BaseEmitter): """Emitter which serializes to XML.""" media_type = 'application/xml' - def emit(self, output=NoContent, verbose=False): - if output is NoContent: + def emit(self, output=None, verbose=False): + if output is None: return '' return dict2xml(output) diff --git a/djangorestframework/modelresource.py b/djangorestframework/modelresource.py index 7fa370532..b0a4b1c12 100644 --- a/djangorestframework/modelresource.py +++ b/djangorestframework/modelresource.py @@ -3,7 +3,7 @@ from django.db.models import Model from django.db.models.query import QuerySet from django.db.models.fields.related import RelatedField -from djangorestframework.response import Response, ResponseException +from djangorestframework.response import Response, ErrorResponse from djangorestframework.resource import Resource from djangorestframework import status, validators @@ -370,7 +370,7 @@ class ModelResource(Resource): # Otherwise assume the kwargs uniquely identify the model instance = self.model.objects.get(**kwargs) except self.model.DoesNotExist: - raise ResponseException(status.HTTP_404_NOT_FOUND) + raise ErrorResponse(status.HTTP_404_NOT_FOUND) return instance @@ -402,7 +402,7 @@ class ModelResource(Resource): # Otherwise assume the kwargs uniquely identify the model instance = self.model.objects.get(**kwargs) except self.model.DoesNotExist: - raise ResponseException(status.HTTP_404_NOT_FOUND, None, {}) + raise ErrorResponse(status.HTTP_404_NOT_FOUND, None, {}) instance.delete() return diff --git a/djangorestframework/parsers.py b/djangorestframework/parsers.py index 11adeb78a..707b61d5d 100644 --- a/djangorestframework/parsers.py +++ b/djangorestframework/parsers.py @@ -9,7 +9,7 @@ We need a method to be able to: and multipart/form-data. (eg also handle multipart/json) """ from django.http.multipartparser import MultiPartParser as DjangoMPParser -from djangorestframework.response import ResponseException +from djangorestframework.response import ErrorResponse from djangorestframework import status from djangorestframework.utils import as_tuple from djangorestframework.mediatypes import MediaType @@ -59,7 +59,7 @@ class JSONParser(BaseParser): try: return json.load(stream) except ValueError, exc: - raise ResponseException(status.HTTP_400_BAD_REQUEST, {'detail': 'JSON parse error - %s' % str(exc)}) + raise ErrorResponse(status.HTTP_400_BAD_REQUEST, {'detail': 'JSON parse error - %s' % str(exc)}) class DataFlatener(object): diff --git a/djangorestframework/resource.py b/djangorestframework/resource.py index 1a02f8b58..f4460c1eb 100644 --- a/djangorestframework/resource.py +++ b/djangorestframework/resource.py @@ -2,9 +2,8 @@ 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.response import Response, ResponseException -from djangorestframework.request import RequestMixin, AuthMixin +from djangorestframework.response import Response, ErrorResponse +from djangorestframework.mixins import RequestMixin, ResponseMixin, AuthMixin from djangorestframework import emitters, parsers, authenticators, validators, status @@ -16,7 +15,7 @@ from djangorestframework import emitters, parsers, authenticators, validators, s __all__ = ['Resource'] -class Resource(EmitterMixin, AuthMixin, RequestMixin, View): +class Resource(RequestMixin, ResponseMixin, AuthMixin, View): """Handles incoming requests and maps them to REST operations, performing authentication, input deserialization, input validation, output serialization.""" @@ -81,7 +80,7 @@ class Resource(EmitterMixin, AuthMixin, RequestMixin, View): 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.""" - raise ResponseException(status.HTTP_500_INTERNAL_SERVER_ERROR, + raise ErrorResponse(status.HTTP_500_INTERNAL_SERVER_ERROR, {'detail': '%s operation on this resource has not been implemented' % (operation, )}) @@ -89,15 +88,15 @@ class Resource(EmitterMixin, AuthMixin, RequestMixin, View): """Ensure the request method is permitted for this resource, raising a ResourceException if it is not.""" if not method in self.callmap.keys(): - raise ResponseException(status.HTTP_501_NOT_IMPLEMENTED, + raise ErrorResponse(status.HTTP_501_NOT_IMPLEMENTED, {'detail': 'Unknown or unsupported method \'%s\'' % method}) if not method in self.allowed_methods: - raise ResponseException(status.HTTP_405_METHOD_NOT_ALLOWED, + raise ErrorResponse(status.HTTP_405_METHOD_NOT_ALLOWED, {'detail': 'Method \'%s\' not allowed on this resource.' % method}) if auth is None and not method in self.anon_allowed_methods: - raise ResponseException(status.HTTP_403_FORBIDDEN, + raise ErrorResponse(status.HTTP_403_FORBIDDEN, {'detail': 'You do not have permission to access this resource. ' + 'You may need to login or otherwise authenticate the request.'}) @@ -172,7 +171,7 @@ class Resource(EmitterMixin, AuthMixin, RequestMixin, View): # Pre-serialize filtering (eg filter complex objects into natively serializable types) response.cleaned_content = self.cleanup_response(response.raw_content) - except ResponseException, exc: + except ErrorResponse, exc: response = exc.response except: @@ -183,8 +182,12 @@ class Resource(EmitterMixin, AuthMixin, RequestMixin, View): # # TODO - this isn't actually the correct way to set the vary header, # also it's currently sub-obtimal for HTTP caching - need to sort that out. - response.headers['Allow'] = ', '.join(self.allowed_methods) - response.headers['Vary'] = 'Authenticate, Accept' + try: + response.headers['Allow'] = ', '.join(self.allowed_methods) + response.headers['Vary'] = 'Authenticate, Accept' - return self.emit(response) + return self.emit(response) + except: + import traceback + traceback.print_exc() diff --git a/djangorestframework/response.py b/djangorestframework/response.py index 809e17546..545a58343 100644 --- a/djangorestframework/response.py +++ b/djangorestframework/response.py @@ -1,24 +1,16 @@ from django.core.handlers.wsgi import STATUS_CODE_TEXT -__all__ =['NoContent', 'Response', ] - - - -class NoContent(object): - """Used to indicate no body in http response. - (We cannot just use None, as that is a valid, serializable response object.) - - TODO: On reflection I'm going to get rid of this and just not support serialized 'None' responses. - """ - pass +__all__ =['Response', 'ErrorResponse'] +# TODO: remove raw_content/cleaned_content and just use content? class Response(object): - def __init__(self, status=200, content=NoContent, headers={}): + """An HttpResponse that may include content that hasn't yet been serialized.""" + def __init__(self, status=200, content=None, headers={}): self.status = status - self.has_content_body = not content is NoContent # TODO: remove and just use content - self.raw_content = content # content prior to filtering - TODO: remove and just use content - self.cleaned_content = content # content after filtering TODO: remove and just use content + self.has_content_body = content is not None + self.raw_content = content # content prior to filtering + self.cleaned_content = content # content after filtering self.headers = headers @property @@ -28,6 +20,7 @@ class Response(object): return STATUS_CODE_TEXT.get(self.status, '') -class ResponseException(BaseException): - def __init__(self, status, content=NoContent, headers={}): +class ErrorResponse(BaseException): + """An exception representing an HttpResponse that should be returned immediatley.""" + def __init__(self, status, content=None, headers={}): self.response = Response(status, content=content, headers=headers) diff --git a/djangorestframework/tests/content.py b/djangorestframework/tests/content.py index b99f30f71..6695bf688 100644 --- a/djangorestframework/tests/content.py +++ b/djangorestframework/tests/content.py @@ -3,7 +3,7 @@ Tests for content parsing, and form-overloaded content parsing. """ from django.test import TestCase from djangorestframework.compat import RequestFactory -from djangorestframework.request import RequestMixin +from djangorestframework.mixins import RequestMixin from djangorestframework.parsers import FormParser, MultipartParser, PlainTextParser diff --git a/djangorestframework/tests/emitters.py b/djangorestframework/tests/emitters.py index 7d024ccfc..21a7eb95d 100644 --- a/djangorestframework/tests/emitters.py +++ b/djangorestframework/tests/emitters.py @@ -2,7 +2,8 @@ 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.emitters import BaseEmitter +from djangorestframework.mixins import ResponseMixin from djangorestframework.response import Response DUMMYSTATUS = 200 @@ -11,7 +12,7 @@ DUMMYCONTENT = 'dummycontent' EMITTER_A_SERIALIZER = lambda x: 'Emitter A: %s' % x EMITTER_B_SERIALIZER = lambda x: 'Emitter B: %s' % x -class MockView(EmitterMixin, View): +class MockView(ResponseMixin, View): def get(self, request): response = Response(DUMMYSTATUS, DUMMYCONTENT) return self.emit(response) diff --git a/djangorestframework/tests/methods.py b/djangorestframework/tests/methods.py index 7f6acf4f8..0e74dc948 100644 --- a/djangorestframework/tests/methods.py +++ b/djangorestframework/tests/methods.py @@ -1,6 +1,6 @@ from django.test import TestCase from djangorestframework.compat import RequestFactory -from djangorestframework.request import RequestMixin +from djangorestframework.mixins import RequestMixin class TestMethodOverloading(TestCase): diff --git a/djangorestframework/tests/validators.py b/djangorestframework/tests/validators.py index a091cf29e..b6563db62 100644 --- a/djangorestframework/tests/validators.py +++ b/djangorestframework/tests/validators.py @@ -3,7 +3,7 @@ from django.db import models from django.test import TestCase from djangorestframework.compat import RequestFactory from djangorestframework.validators import BaseValidator, FormValidator, ModelFormValidator -from djangorestframework.response import ResponseException +from djangorestframework.response import ErrorResponse class TestValidatorMixinInterfaces(TestCase): @@ -81,7 +81,7 @@ class TestNonFieldErrors(TestCase): content = {'field1': 'example1', 'field2': 'example2'} try: FormValidator(view).validate(content) - except ResponseException, exc: + except ErrorResponse, exc: self.assertEqual(exc.response.raw_content, {'errors': [MockForm.ERROR_TEXT]}) else: self.fail('ResourceException was not raised') #pragma: no cover @@ -115,14 +115,14 @@ class TestFormValidation(TestCase): 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) + self.assertRaises(ErrorResponse, 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) + self.assertRaises(ErrorResponse, 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.""" @@ -139,7 +139,7 @@ class TestFormValidation(TestCase): content = {} try: validator.validate(content) - except ResponseException, exc: + except ErrorResponse, exc: self.assertEqual(exc.response.raw_content, {'field-errors': {'qwerty': ['This field is required.']}}) else: self.fail('ResourceException was not raised') #pragma: no cover @@ -149,7 +149,7 @@ class TestFormValidation(TestCase): content = {'qwerty': ''} try: validator.validate(content) - except ResponseException, exc: + except ErrorResponse, exc: self.assertEqual(exc.response.raw_content, {'field-errors': {'qwerty': ['This field is required.']}}) else: self.fail('ResourceException was not raised') #pragma: no cover @@ -159,7 +159,7 @@ class TestFormValidation(TestCase): content = {'qwerty': 'uiop', 'extra': 'extra'} try: validator.validate(content) - except ResponseException, exc: + except ErrorResponse, 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 @@ -169,7 +169,7 @@ class TestFormValidation(TestCase): content = {'qwerty': '', 'extra': 'extra'} try: validator.validate(content) - except ResponseException, exc: + except ErrorResponse, exc: self.assertEqual(exc.response.raw_content, {'field-errors': {'qwerty': ['This field is required.'], 'extra': ['This field does not exist.']}}) else: @@ -286,14 +286,14 @@ class TestModelFormValidator(TestCase): 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.validator.validate, content) + self.assertRaises(ErrorResponse, self.validator.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.validator.validate, content) + self.assertRaises(ErrorResponse, self.validator.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.""" diff --git a/djangorestframework/utils.py b/djangorestframework/utils.py index d45e5acf8..8b12294c7 100644 --- a/djangorestframework/utils.py +++ b/djangorestframework/utils.py @@ -14,6 +14,7 @@ except ImportError: # """Adds the ADMIN_MEDIA_PREFIX to the request context.""" # return {'ADMIN_MEDIA_PREFIX': settings.ADMIN_MEDIA_PREFIX} +MSIE_USER_AGENT_REGEX = re.compile(r'^Mozilla/[0-9]+\.[0-9]+ \([^)]*; MSIE [0-9]+\.[0-9]+[a-z]?;[^)]*\)(?!.* Opera )') def as_tuple(obj): """Given obj return a tuple""" diff --git a/djangorestframework/validators.py b/djangorestframework/validators.py index 609e6d366..c612de551 100644 --- a/djangorestframework/validators.py +++ b/djangorestframework/validators.py @@ -1,7 +1,7 @@ """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.response import ErrorResponse from djangorestframework.utils import as_tuple @@ -13,7 +13,7 @@ class BaseValidator(object): def validate(self, content): """Given some content as input return some cleaned, validated content. - Typically raises a ResponseException with status code 400 (Bad Request) on failure. + Typically raises a ErrorResponse with status code 400 (Bad Request) on failure. Must be overridden to be implemented.""" raise NotImplementedError() @@ -32,11 +32,11 @@ class FormValidator(BaseValidator): 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. + Raises a ErrorResponse 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. + On failure the ErrorResponse 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) @@ -97,7 +97,7 @@ class FormValidator(BaseValidator): detail[u'field-errors'] = field_errors # Return HTTP 400 response (BAD REQUEST) - raise ResponseException(400, detail) + raise ErrorResponse(400, detail) def get_bound_form(self, content=None): @@ -139,14 +139,14 @@ class ModelFormValidator(FormValidator): # 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. + Raises a ErrorResponse 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. + On failure the ErrorResponse 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, allowed_extra_fields=self._property_fields_set) From 6096b50dbe20349144aa92660c6c8467f67f50e7 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 11 Apr 2011 16:54:02 +0100 Subject: [PATCH 16/70] request.py -> mixins.py --- djangorestframework/{request.py => mixins.py} | 173 +++++++++++++++++- 1 file changed, 164 insertions(+), 9 deletions(-) rename djangorestframework/{request.py => mixins.py} (59%) diff --git a/djangorestframework/request.py b/djangorestframework/mixins.py similarity index 59% rename from djangorestframework/request.py rename to djangorestframework/mixins.py index 33d6bb2fc..9af79c665 100644 --- a/djangorestframework/request.py +++ b/djangorestframework/mixins.py @@ -1,12 +1,18 @@ from djangorestframework.mediatypes import MediaType -from djangorestframework.utils import as_tuple -from djangorestframework.response import ResponseException +from djangorestframework.utils import as_tuple, MSIE_USER_AGENT_REGEX +from djangorestframework.response import ErrorResponse from djangorestframework.parsers import FormParser, MultipartParser from djangorestframework import status -#from djangorestframework.requestparsing import parse, load_parser -from django.http.multipartparser import LimitBytes +from django.http import HttpResponse +from django.http.multipartparser import LimitBytes # TODO: Use LimitedStream in compat from StringIO import StringIO +from decimal import Decimal +import re + + + +########## Request Mixin ########## class RequestMixin(object): """Mixin class to provide request parsing behaviour.""" @@ -175,8 +181,8 @@ class RequestMixin(object): """ Parse the request content. - May raise a 415 ResponseException (Unsupported Media Type), - or a 400 ResponseException (Bad Request). + May raise a 415 ErrorResponse (Unsupported Media Type), + or a 400 ErrorResponse (Bad Request). """ if stream is None or content_type is None: return None @@ -190,7 +196,7 @@ class RequestMixin(object): break if parser is None: - raise ResponseException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + raise ErrorResponse(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, {'error': 'Unsupported media type in request \'%s\'.' % content_type.media_type}) @@ -217,12 +223,13 @@ class RequestMixin(object): validator = validator_cls(self) return validator.get_bound_form(content) return None - + @property def parsed_media_types(self): """Return an list of all the media types that this view can parse.""" return [parser.media_type for parser in self.parsers] + @property def default_parser(self): @@ -230,6 +237,7 @@ class RequestMixin(object): (This has no behavioural effect, but is may be used by documenting emitters)""" return self.parsers[0] + method = property(_get_method, _set_method) content_type = property(_get_content_type, _set_content_type) accept = property(_get_accept, _set_accept) @@ -238,6 +246,154 @@ class RequestMixin(object): CONTENT = property(_get_content) +########## ResponseMixin ########## + +class ResponseMixin(object): + """Adds behaviour for pluggable Emitters to a :class:`.Resource` or Django :class:`View`. class. + + Default behaviour is to use standard HTTP Accept header content negotiation. + Also supports overidding the content type by specifying an _accept= parameter in the URL. + Ignores Accept headers from Internet Explorer user agents and uses a sensible browser Accept header instead.""" + + ACCEPT_QUERY_PARAM = '_accept' # Allow override of Accept header in URL query params + REWRITE_IE_ACCEPT_HEADER = True + + #request = None + #response = None + emitters = () + + #def render_to_response(self, obj): + # if isinstance(obj, Response): + # response = obj + # elif response_obj is not None: + # response = Response(status.HTTP_200_OK, obj) + # else: + # response = Response(status.HTTP_204_NO_CONTENT) + + # response.cleaned_content = self._filter(response.raw_content) + + # self._render(response) + + + #def filter(self, content): + # """ + # Filter the response content. + # """ + # for filterer_cls in self.filterers: + # filterer = filterer_cls(self) + # content = filterer.filter(content) + # return content + + + def emit(self, response): + """Takes a :class:`Response` object and returns a Django :class:`HttpResponse`.""" + self.response = response + + try: + emitter = self._determine_emitter(self.request) + except ErrorResponse, 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_REGEX.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 ErrorResponse(status.HTTP_406_NOT_ACCEPTABLE, + {'detail': 'Could not satisfy 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] + + +########## Auth Mixin ########## class AuthMixin(object): """Mixin class to provide authentication and permissions.""" @@ -277,4 +433,3 @@ class AuthMixin(object): if auth: return auth return None - From 18bbda84b9c663fec6eede112a21cf1a48103303 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 11 Apr 2011 17:13:11 +0100 Subject: [PATCH 17/70] depercate auth and content arguments to the request handler methods - yea :) --- djangorestframework/authenticators.py | 5 +++-- djangorestframework/modelresource.py | 18 +++++++++--------- djangorestframework/resource.py | 14 +++++--------- djangorestframework/tests/accept.py | 2 +- djangorestframework/tests/authentication.py | 2 +- djangorestframework/tests/files.py | 7 +++---- djangorestframework/tests/reverse.py | 2 +- 7 files changed, 23 insertions(+), 27 deletions(-) diff --git a/djangorestframework/authenticators.py b/djangorestframework/authenticators.py index e382de10a..e8331cc70 100644 --- a/djangorestframework/authenticators.py +++ b/djangorestframework/authenticators.py @@ -28,8 +28,9 @@ class BaseAuthenticator(object): 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. - The authentication context is passed to the method calls eg Resource.get(request, auth) in order to - allow them to apply any more fine grained permission checking at the point the response is being generated. + The authentication context is available to the method calls eg Resource.get(request) + by accessing self.auth in order to allow them to apply any more fine grained permission + checking at the point the response is being generated. This function must be overridden to be implemented.""" return None diff --git a/djangorestframework/modelresource.py b/djangorestframework/modelresource.py index b0a4b1c12..23a87e656 100644 --- a/djangorestframework/modelresource.py +++ b/djangorestframework/modelresource.py @@ -341,7 +341,7 @@ class ModelResource(Resource): return _any(data, self.fields) - def post(self, request, auth, content, *args, **kwargs): + def post(self, request, *args, **kwargs): # TODO: test creation on a non-existing resource url # translated related_field into related_field_id @@ -350,7 +350,7 @@ class ModelResource(Resource): kwargs[related_name + '_id'] = kwargs[related_name] del kwargs[related_name] - all_kw_args = dict(content.items() + kwargs.items()) + all_kw_args = dict(self.CONTENT.items() + kwargs.items()) if args: instance = self.model(pk=args[-1], **all_kw_args) else: @@ -361,7 +361,7 @@ class ModelResource(Resource): headers['Location'] = instance.get_absolute_url() return Response(status.HTTP_201_CREATED, instance, headers) - def get(self, request, auth, *args, **kwargs): + def get(self, request, *args, **kwargs): try: if args: # If we have any none kwargs then assume the last represents the primrary key @@ -374,7 +374,7 @@ class ModelResource(Resource): return instance - def put(self, request, auth, content, *args, **kwargs): + def put(self, request, *args, **kwargs): # TODO: update on the url of a non-existing resource url doesn't work correctly at the moment - will end up with a new url try: if args: @@ -384,16 +384,16 @@ class ModelResource(Resource): # Otherwise assume the kwargs uniquely identify the model instance = self.model.objects.get(**kwargs) - for (key, val) in content.items(): + for (key, val) in self.CONTENT.items(): setattr(instance, key, val) except self.model.DoesNotExist: - instance = self.model(**content) + instance = self.model(**self.CONTENT) instance.save() instance.save() return instance - def delete(self, request, auth, *args, **kwargs): + def delete(self, request, *args, **kwargs): try: if args: # If we have any none kwargs then assume the last represents the primrary key @@ -413,7 +413,7 @@ class RootModelResource(ModelResource): allowed_methods = ('GET', 'POST') queryset = None - def get(self, request, auth, *args, **kwargs): + def get(self, request, *args, **kwargs): queryset = self.queryset if self.queryset else self.model.objects.all() return queryset.filter(**kwargs) @@ -427,7 +427,7 @@ class QueryModelResource(ModelResource): def get_form(self, data=None): return None - def get(self, request, auth, *args, **kwargs): + def get(self, request, *args, **kwargs): queryset = self.queryset if self.queryset else self.model.objects.all() return queryset.filer(**kwargs) diff --git a/djangorestframework/resource.py b/djangorestframework/resource.py index f4460c1eb..0615a1647 100644 --- a/djangorestframework/resource.py +++ b/djangorestframework/resource.py @@ -57,22 +57,22 @@ class Resource(RequestMixin, ResponseMixin, AuthMixin, View): callmap = { 'GET': 'get', 'POST': 'post', 'PUT': 'put', 'DELETE': 'delete' } - def get(self, request, auth, *args, **kwargs): + def get(self, request, *args, **kwargs): """Must be subclassed to be implemented.""" self.not_implemented('GET') - def post(self, request, auth, content, *args, **kwargs): + def post(self, request, *args, **kwargs): """Must be subclassed to be implemented.""" self.not_implemented('POST') - def put(self, request, auth, content, *args, **kwargs): + def put(self, request, *args, **kwargs): """Must be subclassed to be implemented.""" self.not_implemented('PUT') - def delete(self, request, auth, *args, **kwargs): + def delete(self, request, *args, **kwargs): """Must be subclassed to be implemented.""" self.not_implemented('DELETE') @@ -154,11 +154,7 @@ class Resource(RequestMixin, ResponseMixin, AuthMixin, View): # Either generate the response data, deserializing and validating any request data # TODO: This is going to change to: func(request, *args, **kwargs) # That'll work out now that we have the lazily evaluated self.CONTENT property. - if self.method in ('PUT', 'POST'): - response_obj = func(request, auth_context, self.CONTENT, *args, **kwargs) - - else: - response_obj = func(request, auth_context, *args, **kwargs) + response_obj = func(request, *args, **kwargs) # Allow return value to be either Response, or an object, or None if isinstance(response_obj, Response): diff --git a/djangorestframework/tests/accept.py b/djangorestframework/tests/accept.py index f2a21277d..726e12521 100644 --- a/djangorestframework/tests/accept.py +++ b/djangorestframework/tests/accept.py @@ -20,7 +20,7 @@ class UserAgentMungingTest(TestCase): def setUp(self): class MockResource(Resource): anon_allowed_methods = allowed_methods = ('GET',) - def get(self, request, auth): + def get(self, request): return {'a':1, 'b':2, 'c':3} self.req = RequestFactory() self.MockResource = MockResource diff --git a/djangorestframework/tests/authentication.py b/djangorestframework/tests/authentication.py index af9c34ca9..b2bc4446f 100644 --- a/djangorestframework/tests/authentication.py +++ b/djangorestframework/tests/authentication.py @@ -15,7 +15,7 @@ except ImportError: class MockResource(Resource): allowed_methods = ('POST',) - def post(self, request, auth, content): + def post(self, request): return {'a':1, 'b':2, 'c':3} urlpatterns = patterns('', diff --git a/djangorestframework/tests/files.py b/djangorestframework/tests/files.py index e155f181f..dd4689a62 100644 --- a/djangorestframework/tests/files.py +++ b/djangorestframework/tests/files.py @@ -19,10 +19,9 @@ class UploadFilesTests(TestCase): allowed_methods = anon_allowed_methods = ('POST',) form = FileForm - def post(self, request, auth, content, *args, **kwargs): - #self.uploaded = content.file - return {'FILE_NAME': content['file'].name, - 'FILE_CONTENT': content['file'].read()} + def post(self, request, *args, **kwargs): + return {'FILE_NAME': self.CONTENT['file'].name, + 'FILE_CONTENT': self.CONTENT['file'].read()} file = StringIO.StringIO('stuff') file.name = 'stuff.txt' diff --git a/djangorestframework/tests/reverse.py b/djangorestframework/tests/reverse.py index a862e39a6..f6a3ea516 100644 --- a/djangorestframework/tests/reverse.py +++ b/djangorestframework/tests/reverse.py @@ -14,7 +14,7 @@ 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): + def get(self, request): return reverse('another') urlpatterns = patterns('', From cb4b4f6be6eeac3d2383614998a5e1436cb4226e Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 11 Apr 2011 17:18:03 +0100 Subject: [PATCH 18/70] Remove some temporary debugging stuff --- djangorestframework/resource.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/djangorestframework/resource.py b/djangorestframework/resource.py index 0615a1647..1e79c79fe 100644 --- a/djangorestframework/resource.py +++ b/djangorestframework/resource.py @@ -169,21 +169,13 @@ class Resource(RequestMixin, ResponseMixin, AuthMixin, View): except ErrorResponse, exc: response = exc.response - - except: - import traceback - traceback.print_exc() # Always add these headers. # # TODO - this isn't actually the correct way to set the vary header, # also it's currently sub-obtimal for HTTP caching - need to sort that out. - try: - response.headers['Allow'] = ', '.join(self.allowed_methods) - response.headers['Vary'] = 'Authenticate, Accept' + response.headers['Allow'] = ', '.join(self.allowed_methods) + response.headers['Vary'] = 'Authenticate, Accept' - return self.emit(response) - except: - import traceback - traceback.print_exc() + return self.emit(response) From 4692374e0d6f020f8a7a95f3a60094d525c59341 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 25 Apr 2011 01:03:23 +0100 Subject: [PATCH 19/70] Generic permissions added, allowed_methods and anon_allowed_methods now defunct, dispatch now mirrors View.dispatch more nicely --- djangorestframework/authenticators.py | 12 +- djangorestframework/mixins.py | 30 ++-- djangorestframework/modelresource.py | 6 +- djangorestframework/resource.py | 165 ++++++++------------ djangorestframework/tests/accept.py | 5 +- djangorestframework/tests/authentication.py | 2 - djangorestframework/tests/files.py | 2 +- djangorestframework/tests/reverse.py | 8 +- 8 files changed, 99 insertions(+), 131 deletions(-) diff --git a/djangorestframework/authenticators.py b/djangorestframework/authenticators.py index e8331cc70..e6f51dd57 100644 --- a/djangorestframework/authenticators.py +++ b/djangorestframework/authenticators.py @@ -13,10 +13,10 @@ import base64 class BaseAuthenticator(object): """All authenticators should extend BaseAuthenticator.""" - def __init__(self, mixin): + def __init__(self, view): """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 + self.view = view def authenticate(self, request): """Authenticate the request and return the authentication context or None. @@ -61,11 +61,13 @@ class BasicAuthenticator(BaseAuthenticator): class UserLoggedInAuthenticator(BaseAuthenticator): - """Use Djagno's built-in request session for authentication.""" + """Use Django's built-in request session for authentication.""" def authenticate(self, request): if getattr(request, 'user', None) and request.user.is_active: - # Temporarily request.POST with .RAW_CONTENT, so that we use our more generic request parsing - request._post = self.mixin.RAW_CONTENT + # Temporarily set request.POST to view.RAW_CONTENT, + # so that we use our more generic request parsing, + # in preference to Django's form-only request parsing. + request._post = self.view.RAW_CONTENT resp = CsrfViewMiddleware().process_view(request, None, (), {}) del(request._post) if resp is None: # csrf passed diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 9af79c665..53262366a 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -396,9 +396,9 @@ class ResponseMixin(object): ########## Auth Mixin ########## class AuthMixin(object): - """Mixin class to provide authentication and permissions.""" + """Mixin class to provide authentication and permission checking.""" authenticators = () - permitters = () + permissions = () @property def auth(self): @@ -406,6 +406,14 @@ class AuthMixin(object): self._auth = self._authenticate() return self._auth + def _authenticate(self): + for authenticator_cls in self.authenticators: + authenticator = authenticator_cls(self) + auth = authenticator.authenticate(self.request) + if auth: + return auth + return None + # TODO? #@property #def user(self): @@ -421,15 +429,11 @@ class AuthMixin(object): if not self.permissions: return - auth = self.auth - for permitter_cls in self.permitters: - permitter = permission_cls(self) - permitter.permit(auth) + for permission_cls in self.permissions: + permission = permission_cls(self) + if not permission.has_permission(self.auth): + raise ErrorResponse(status.HTTP_403_FORBIDDEN, + {'detail': 'You do not have permission to access this resource. ' + + 'You may need to login or otherwise authenticate the request.'}) + - def _authenticate(self): - for authenticator_cls in self.authenticators: - authenticator = authenticator_cls(self) - auth = authenticator.authenticate(self.request) - if auth: - return auth - return None diff --git a/djangorestframework/modelresource.py b/djangorestframework/modelresource.py index 23a87e656..24fb62ab8 100644 --- a/djangorestframework/modelresource.py +++ b/djangorestframework/modelresource.py @@ -410,13 +410,13 @@ class ModelResource(Resource): class RootModelResource(ModelResource): """A Resource which provides default operations for list and create.""" - allowed_methods = ('GET', 'POST') queryset = None def get(self, request, *args, **kwargs): queryset = self.queryset if self.queryset else self.model.objects.all() return queryset.filter(**kwargs) + put = delete = http_method_not_allowed class QueryModelResource(ModelResource): """Resource with default operations for list. @@ -424,10 +424,8 @@ class QueryModelResource(ModelResource): allowed_methods = ('GET',) queryset = None - def get_form(self, data=None): - return None - def get(self, request, *args, **kwargs): queryset = self.queryset if self.queryset else self.model.objects.all() return queryset.filer(**kwargs) + post = put = delete = http_method_not_allowed \ No newline at end of file diff --git a/djangorestframework/resource.py b/djangorestframework/resource.py index 1e79c79fe..55a9b57d9 100644 --- a/djangorestframework/resource.py +++ b/djangorestframework/resource.py @@ -4,7 +4,7 @@ from django.views.decorators.csrf import csrf_exempt from djangorestframework.compat import View from djangorestframework.response import Response, ErrorResponse from djangorestframework.mixins import RequestMixin, ResponseMixin, AuthMixin -from djangorestframework import emitters, parsers, authenticators, validators, status +from djangorestframework import emitters, parsers, authenticators, permissions, validators, status # TODO: Figure how out references and named urls need to work nicely @@ -19,11 +19,7 @@ class Resource(RequestMixin, ResponseMixin, AuthMixin, View): """Handles incoming requests and maps them to REST operations, performing authentication, input deserialization, input validation, output serialization.""" - # List of RESTful operations which may be performed on this resource. - # These are going to get dropped at some point, the allowable methods will be defined simply by - # which methods are present on the request (in the same way as Django's generic View) - allowed_methods = ('GET',) - anon_allowed_methods = () + http_method_names = ['get', 'post', 'put', 'delete', 'head', 'options', 'trace', 'patch'] # List of emitters the resource can serialize the response with, ordered by preference. emitters = ( emitters.JSONEmitter, @@ -37,12 +33,15 @@ class Resource(RequestMixin, ResponseMixin, AuthMixin, View): parsers.FormParser, parsers.MultipartParser ) - # List of validators to validate, cleanup and type-ify the request content + # List of validators to validate, cleanup and normalize the request content validators = ( validators.FormValidator, ) # List of all authenticating methods to attempt. authenticators = ( authenticators.UserLoggedInAuthenticator, authenticators.BasicAuthenticator ) + + # List of all permissions required to access the resource + permissions = ( permissions.DeleteMePermission, ) # Optional form for input validation and presentation of HTML formatted responses. form = None @@ -53,52 +52,14 @@ class Resource(RequestMixin, ResponseMixin, AuthMixin, View): name = None description = None - # Map standard HTTP methods to function calls - callmap = { 'GET': 'get', 'POST': 'post', - 'PUT': 'put', 'DELETE': 'delete' } + @property + def allowed_methods(self): + return [method.upper() for method in self.http_method_names if hasattr(self, method)] - def get(self, request, *args, **kwargs): - """Must be subclassed to be implemented.""" - self.not_implemented('GET') - - - def post(self, request, *args, **kwargs): - """Must be subclassed to be implemented.""" - self.not_implemented('POST') - - - def put(self, request, *args, **kwargs): - """Must be subclassed to be implemented.""" - self.not_implemented('PUT') - - - def delete(self, request, *args, **kwargs): - """Must be subclassed to be implemented.""" - self.not_implemented('DELETE') - - - 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.""" - raise ErrorResponse(status.HTTP_500_INTERNAL_SERVER_ERROR, - {'detail': '%s operation on this resource has not been implemented' % (operation, )}) - - - def check_method_allowed(self, method, auth): - """Ensure the request method is permitted for this resource, raising a ResourceException if it is not.""" - - if not method in self.callmap.keys(): - raise ErrorResponse(status.HTTP_501_NOT_IMPLEMENTED, - {'detail': 'Unknown or unsupported method \'%s\'' % method}) - - if not method in self.allowed_methods: - raise ErrorResponse(status.HTTP_405_METHOD_NOT_ALLOWED, - {'detail': 'Method \'%s\' not allowed on this resource.' % method}) - - if auth is None and not method in self.anon_allowed_methods: - raise ErrorResponse(status.HTTP_403_FORBIDDEN, - {'detail': 'You do not have permission to access this resource. ' + - 'You may need to login or otherwise authenticate the request.'}) + def http_method_not_allowed(self, request, *args, **kwargs): + """Return an HTTP 405 error if an operation is called which does not have a handler method.""" + raise ErrorResponse(status.HTTP_405_METHOD_NOT_ALLOWED, + {'detail': 'Method \'%s\' not allowed on this resource.' % self.method}) def cleanup_response(self, data): @@ -111,6 +72,7 @@ class Resource(RequestMixin, ResponseMixin, AuthMixin, View): the EmitterMixin and Emitter classes.""" return data + # Session based authentication is explicitly CSRF validated, all other authentication is CSRF exempt. @csrf_exempt def dispatch(self, request, *args, **kwargs): @@ -125,57 +87,54 @@ class Resource(RequestMixin, ResponseMixin, AuthMixin, View): 4. cleanup the response data 5. serialize response data into response content, using standard HTTP content negotiation """ - - 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) - try: - # Authenticate the request, and store any context so that the resource operations can - # do more fine grained authentication if required. - # - # Typically the context will be a user, or None if this is an anonymous request, - # but it could potentially be more complex (eg the context of a request key which - # has been signed against a particular set of permissions) - auth_context = self.auth - - # If using a form POST with '_method'/'_content'/'_content_type' overrides, then alter - # self.method, self.content_type, self.CONTENT appropriately. - self.perform_form_overloading() - - # Ensure the requested operation is permitted on this resource - self.check_method_allowed(self.method, auth_context) - - # Get the appropriate create/read/update/delete function - func = getattr(self, self.callmap.get(self.method, None)) + self.request = request + self.args = args + self.kwargs = kwargs - # Either generate the response data, deserializing and validating any request data - # TODO: This is going to change to: func(request, *args, **kwargs) - # That'll work out now that we have the lazily evaluated self.CONTENT property. - response_obj = func(request, *args, **kwargs) - - # Allow return value to be either Response, or an object, or None - if isinstance(response_obj, Response): - response = response_obj - elif response_obj is not None: - response = Response(status.HTTP_200_OK, response_obj) - else: - response = Response(status.HTTP_204_NO_CONTENT) - - # Pre-serialize filtering (eg filter complex objects into natively serializable types) - response.cleaned_content = self.cleanup_response(response.raw_content) - - except ErrorResponse, exc: - response = exc.response - - # Always add these headers. - # - # TODO - this isn't actually the correct way to set the vary header, - # also it's currently sub-obtimal for HTTP caching - need to sort that out. - response.headers['Allow'] = ', '.join(self.allowed_methods) - response.headers['Vary'] = 'Authenticate, Accept' - - return self.emit(response) + # 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) + + try: + # If using a form POST with '_method'/'_content'/'_content_type' overrides, then alter + # self.method, self.content_type, self.RAW_CONTENT & self.CONTENT appropriately. + self.perform_form_overloading() + + # Authenticate and check request is has the relevant permissions + self.check_permissions() + + # Get the appropriate handler method + if self.method.lower() in self.http_method_names: + handler = getattr(self, self.method.lower(), self.http_method_not_allowed) + else: + handler = self.http_method_not_allowed + + response_obj = handler(request, *args, **kwargs) + + # Allow return value to be either Response, or an object, or None + if isinstance(response_obj, Response): + response = response_obj + elif response_obj is not None: + response = Response(status.HTTP_200_OK, response_obj) + else: + response = Response(status.HTTP_204_NO_CONTENT) + + # Pre-serialize filtering (eg filter complex objects into natively serializable types) + response.cleaned_content = self.cleanup_response(response.raw_content) + + except ErrorResponse, exc: + response = exc.response + + # Always add these headers. + # + # TODO - this isn't actually the correct way to set the vary header, + # also it's currently sub-obtimal for HTTP caching - need to sort that out. + response.headers['Allow'] = ', '.join(self.allowed_methods) + response.headers['Vary'] = 'Authenticate, Accept' + + return self.emit(response) + except: + import traceback + traceback.print_exc() diff --git a/djangorestframework/tests/accept.py b/djangorestframework/tests/accept.py index 726e12521..b12dc7574 100644 --- a/djangorestframework/tests/accept.py +++ b/djangorestframework/tests/accept.py @@ -18,10 +18,13 @@ class UserAgentMungingTest(TestCase): http://www.gethifi.com/blog/browser-rest-http-accept-headers""" def setUp(self): + class MockResource(Resource): - anon_allowed_methods = allowed_methods = ('GET',) + permissions = () + def get(self, request): return {'a':1, 'b':2, 'c':3} + self.req = RequestFactory() self.MockResource = MockResource self.view = MockResource.as_view() diff --git a/djangorestframework/tests/authentication.py b/djangorestframework/tests/authentication.py index b2bc4446f..c825883d7 100644 --- a/djangorestframework/tests/authentication.py +++ b/djangorestframework/tests/authentication.py @@ -13,8 +13,6 @@ except ImportError: import simplejson as json class MockResource(Resource): - allowed_methods = ('POST',) - def post(self, request): return {'a':1, 'b':2, 'c':3} diff --git a/djangorestframework/tests/files.py b/djangorestframework/tests/files.py index dd4689a62..4dc3aa401 100644 --- a/djangorestframework/tests/files.py +++ b/djangorestframework/tests/files.py @@ -16,7 +16,7 @@ class UploadFilesTests(TestCase): file = forms.FileField class MockResource(Resource): - allowed_methods = anon_allowed_methods = ('POST',) + permissions = () form = FileForm def post(self, request, *args, **kwargs): diff --git a/djangorestframework/tests/reverse.py b/djangorestframework/tests/reverse.py index f6a3ea516..1f9071b33 100644 --- a/djangorestframework/tests/reverse.py +++ b/djangorestframework/tests/reverse.py @@ -12,7 +12,7 @@ except ImportError: 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',) + permissions = () def get(self, request): return reverse('another') @@ -28,5 +28,9 @@ class ReverseTests(TestCase): urls = 'djangorestframework.tests.reverse' def test_reversed_urls_are_fully_qualified(self): - response = self.client.get('/') + try: + response = self.client.get('/') + except: + import traceback + traceback.print_exc() self.assertEqual(json.loads(response.content), 'http://testserver/another') From 84a4fd3ea11a55441cb5b8acd584c76fc325edcc Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 25 Apr 2011 04:48:55 +0100 Subject: [PATCH 20/70] tidy up --- djangorestframework/modelresource.py | 26 +++---- djangorestframework/resource.py | 109 ++++++++++++--------------- 2 files changed, 61 insertions(+), 74 deletions(-) diff --git a/djangorestframework/modelresource.py b/djangorestframework/modelresource.py index 24fb62ab8..a91c79ee6 100644 --- a/djangorestframework/modelresource.py +++ b/djangorestframework/modelresource.py @@ -341,6 +341,19 @@ class ModelResource(Resource): return _any(data, self.fields) + def get(self, request, *args, **kwargs): + try: + if args: + # If we have any none kwargs then assume the last represents the primrary key + instance = self.model.objects.get(pk=args[-1], **kwargs) + else: + # Otherwise assume the kwargs uniquely identify the model + instance = self.model.objects.get(**kwargs) + except self.model.DoesNotExist: + raise ErrorResponse(status.HTTP_404_NOT_FOUND) + + return instance + def post(self, request, *args, **kwargs): # TODO: test creation on a non-existing resource url @@ -361,19 +374,6 @@ class ModelResource(Resource): headers['Location'] = instance.get_absolute_url() return Response(status.HTTP_201_CREATED, instance, headers) - def get(self, request, *args, **kwargs): - try: - if args: - # If we have any none kwargs then assume the last represents the primrary key - instance = self.model.objects.get(pk=args[-1], **kwargs) - else: - # Otherwise assume the kwargs uniquely identify the model - instance = self.model.objects.get(**kwargs) - except self.model.DoesNotExist: - raise ErrorResponse(status.HTTP_404_NOT_FOUND) - - return instance - def put(self, request, *args, **kwargs): # TODO: update on the url of a non-existing resource url doesn't work correctly at the moment - will end up with a new url try: diff --git a/djangorestframework/resource.py b/djangorestframework/resource.py index 55a9b57d9..65aa09c64 100644 --- a/djangorestframework/resource.py +++ b/djangorestframework/resource.py @@ -16,8 +16,8 @@ __all__ = ['Resource'] class Resource(RequestMixin, ResponseMixin, AuthMixin, View): - """Handles incoming requests and maps them to REST operations, - performing authentication, input deserialization, input validation, output serialization.""" + """Handles incoming requests and maps them to REST operations. + Performs request deserialization, response serialization, authentication and input validation.""" http_method_names = ['get', 'post', 'put', 'delete', 'head', 'options', 'trace', 'patch'] @@ -73,68 +73,55 @@ class Resource(RequestMixin, ResponseMixin, AuthMixin, View): return data - # Session based authentication is explicitly CSRF validated, all other authentication is CSRF exempt. + # Note: session based authentication is explicitly CSRF validated, + # all other authentication is CSRF exempt. @csrf_exempt def dispatch(self, request, *args, **kwargs): - """This method is the core of Resource, through which all requests are passed. + self.request = request + self.args = args + self.kwargs = kwargs - Broadly this consists of the following procedure: + # 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) - 0. ensure the operation is permitted - 1. deserialize request content into request data, using standard HTTP content types (PUT/POST only) - 2. cleanup and validate request data (PUT/POST only) - 3. call the core method to get the response data - 4. cleanup the response data - 5. serialize response data into response content, using standard HTTP content negotiation - """ try: - self.request = request - self.args = args - self.kwargs = kwargs - - # 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) - - try: - # If using a form POST with '_method'/'_content'/'_content_type' overrides, then alter - # self.method, self.content_type, self.RAW_CONTENT & self.CONTENT appropriately. - self.perform_form_overloading() - - # Authenticate and check request is has the relevant permissions - self.check_permissions() - - # Get the appropriate handler method - if self.method.lower() in self.http_method_names: - handler = getattr(self, self.method.lower(), self.http_method_not_allowed) - else: - handler = self.http_method_not_allowed - - response_obj = handler(request, *args, **kwargs) - - # Allow return value to be either Response, or an object, or None - if isinstance(response_obj, Response): - response = response_obj - elif response_obj is not None: - response = Response(status.HTTP_200_OK, response_obj) - else: - response = Response(status.HTTP_204_NO_CONTENT) - - # Pre-serialize filtering (eg filter complex objects into natively serializable types) - response.cleaned_content = self.cleanup_response(response.raw_content) - - except ErrorResponse, exc: - response = exc.response - - # Always add these headers. - # - # TODO - this isn't actually the correct way to set the vary header, - # also it's currently sub-obtimal for HTTP caching - need to sort that out. - response.headers['Allow'] = ', '.join(self.allowed_methods) - response.headers['Vary'] = 'Authenticate, Accept' - - return self.emit(response) - except: - import traceback - traceback.print_exc() + # If using a form POST with '_method'/'_content'/'_content_type' overrides, then alter + # self.method, self.content_type, self.RAW_CONTENT & self.CONTENT appropriately. + self.perform_form_overloading() + + # Authenticate and check request is has the relevant permissions + self.check_permissions() + + # Get the appropriate handler method + if self.method.lower() in self.http_method_names: + handler = getattr(self, self.method.lower(), self.http_method_not_allowed) + else: + handler = self.http_method_not_allowed + + response_obj = handler(request, *args, **kwargs) + + # Allow return value to be either Response, or an object, or None + if isinstance(response_obj, Response): + response = response_obj + elif response_obj is not None: + response = Response(status.HTTP_200_OK, response_obj) + else: + response = Response(status.HTTP_204_NO_CONTENT) + + # Pre-serialize filtering (eg filter complex objects into natively serializable types) + response.cleaned_content = self.cleanup_response(response.raw_content) + + except ErrorResponse, exc: + response = exc.response + + # Always add these headers. + # + # TODO - this isn't actually the correct way to set the vary header, + # also it's currently sub-obtimal for HTTP caching - need to sort that out. + response.headers['Allow'] = ', '.join(self.allowed_methods) + response.headers['Vary'] = 'Authenticate, Accept' + + return self.emit(response) + From 762a52edde09297e87c640797219c9bb8255d50a Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 25 Apr 2011 04:50:28 +0100 Subject: [PATCH 21/70] Fix some compat issues with json/simplejson --- djangorestframework/compat.py | 10 +++++++++- djangorestframework/emitters.py | 6 +----- djangorestframework/parsers.py | 13 +++---------- djangorestframework/tests/authentication.py | 12 ++++++------ djangorestframework/tests/reverse.py | 5 +---- examples/blogpost/tests.py | 13 +++++-------- examples/pygments_api/tests.py | 8 ++++---- 7 files changed, 29 insertions(+), 38 deletions(-) diff --git a/djangorestframework/compat.py b/djangorestframework/compat.py index 3e82bd98a..22b571861 100644 --- a/djangorestframework/compat.py +++ b/djangorestframework/compat.py @@ -125,4 +125,12 @@ except: # 'request': self.request # } #) - return http.HttpResponseNotAllowed(allowed_methods) \ No newline at end of file + return http.HttpResponseNotAllowed(allowed_methods) + +# parse_qs +try: + # python >= ? + from urlparse import parse_qs +except ImportError: + # python <= ? + from cgi import parse_qs \ No newline at end of file diff --git a/djangorestframework/emitters.py b/djangorestframework/emitters.py index 2990d313e..39046919d 100644 --- a/djangorestframework/emitters.py +++ b/djangorestframework/emitters.py @@ -5,6 +5,7 @@ and providing forms and links depending on the allowed methods, emitters and par """ from django.conf import settings from django.template import RequestContext, loader +from django.utils import simplejson as json from django import forms from djangorestframework.response import ErrorResponse @@ -18,11 +19,6 @@ from urllib import quote_plus import string import re -try: - import json -except ImportError: - import simplejson as json - # TODO: Rename verbose to something more appropriate diff --git a/djangorestframework/parsers.py b/djangorestframework/parsers.py index 707b61d5d..caa762779 100644 --- a/djangorestframework/parsers.py +++ b/djangorestframework/parsers.py @@ -9,20 +9,13 @@ We need a method to be able to: and multipart/form-data. (eg also handle multipart/json) """ from django.http.multipartparser import MultiPartParser as DjangoMPParser +from django.utils import simplejson as json + from djangorestframework.response import ErrorResponse from djangorestframework import status from djangorestframework.utils import as_tuple from djangorestframework.mediatypes import MediaType - -try: - import json -except ImportError: - import simplejson as json - -try: - from urlparse import parse_qs -except ImportError: - from cgi import parse_qs +from djangorestframework.compat import parse_qs diff --git a/djangorestframework/tests/authentication.py b/djangorestframework/tests/authentication.py index c825883d7..723005069 100644 --- a/djangorestframework/tests/authentication.py +++ b/djangorestframework/tests/authentication.py @@ -1,16 +1,15 @@ 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 +from django.utils import simplejson as json + +from djangorestframework.compat import RequestFactory +from djangorestframework.resource import Resource import base64 -try: - import json -except ImportError: - import simplejson as json + class MockResource(Resource): def post(self, request): @@ -86,3 +85,4 @@ class SessionAuthTests(TestCase): """Ensure POSTing form over session authentication without logged in user fails.""" response = self.csrf_client.post('/', {'example': 'example'}) self.assertEqual(response.status_code, 403) + diff --git a/djangorestframework/tests/reverse.py b/djangorestframework/tests/reverse.py index 1f9071b33..28fee63bd 100644 --- a/djangorestframework/tests/reverse.py +++ b/djangorestframework/tests/reverse.py @@ -1,13 +1,10 @@ from django.conf.urls.defaults import patterns, url from django.core.urlresolvers import reverse from django.test import TestCase +from django.utils import simplejson as json from djangorestframework.resource import Resource -try: - import json -except ImportError: - import simplejson as json class MockResource(Resource): diff --git a/examples/blogpost/tests.py b/examples/blogpost/tests.py index dfb4d5f57..14b0914d0 100644 --- a/examples/blogpost/tests.py +++ b/examples/blogpost/tests.py @@ -1,13 +1,15 @@ """Test a range of REST API usage of the example application. """ -from django.test import TestCase from django.core.urlresolvers import reverse +from django.test import TestCase +from django.utils import simplejson as json + +from djangorestframework.compat import RequestFactory + from blogpost import views, models import blogpost -#import json -#from rest.utils import xml2dict, dict2xml class AcceptHeaderTests(TestCase): """Test correct behaviour of the Accept header as specified by RFC 2616: @@ -164,11 +166,6 @@ class AllowedMethodsTests(TestCase): #above testcases need to probably moved to the core -from djangorestframework.compat import RequestFactory -try: - import json -except ImportError: - import simplejson as json class TestRotation(TestCase): """For the example the maximum amount of Blogposts is capped off at views.MAX_POSTS. diff --git a/examples/pygments_api/tests.py b/examples/pygments_api/tests.py index 017823b93..a8f085cfb 100644 --- a/examples/pygments_api/tests.py +++ b/examples/pygments_api/tests.py @@ -1,12 +1,12 @@ from django.test import TestCase +from django.utils import simplejson as json + from djangorestframework.compat import RequestFactory + from pygments_api import views import tempfile, shutil -try: - import json -except ImportError: - import simplejson as json + class TestPygmentsExample(TestCase): From 028851bfa1ee44b8e92808b18d32278d4a473cc8 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 27 Apr 2011 18:07:28 +0100 Subject: [PATCH 22/70] Fix up tests and examples after refactoring --- djangorestframework/authenticators.py | 8 ++++++- djangorestframework/modelresource.py | 4 ++-- djangorestframework/resource.py | 8 +++++-- djangorestframework/tests/authentication.py | 2 ++ examples/blogpost/views.py | 4 ---- examples/mixin/urls.py | 5 +++-- examples/modelresourceexample/views.py | 2 -- examples/pygments_api/views.py | 24 ++++++++++----------- examples/resourceexample/views.py | 10 ++++----- examples/sandbox/views.py | 3 +-- 10 files changed, 36 insertions(+), 34 deletions(-) diff --git a/djangorestframework/authenticators.py b/djangorestframework/authenticators.py index e6f51dd57..19181b7d6 100644 --- a/djangorestframework/authenticators.py +++ b/djangorestframework/authenticators.py @@ -73,4 +73,10 @@ class UserLoggedInAuthenticator(BaseAuthenticator): if resp is None: # csrf passed return request.user return None - + + +#class DigestAuthentication(BaseAuthentication): +# pass +# +#class OAuthAuthentication(BaseAuthentication): +# pass diff --git a/djangorestframework/modelresource.py b/djangorestframework/modelresource.py index a91c79ee6..1afd7fa03 100644 --- a/djangorestframework/modelresource.py +++ b/djangorestframework/modelresource.py @@ -416,7 +416,7 @@ class RootModelResource(ModelResource): queryset = self.queryset if self.queryset else self.model.objects.all() return queryset.filter(**kwargs) - put = delete = http_method_not_allowed + put = delete = None class QueryModelResource(ModelResource): """Resource with default operations for list. @@ -428,4 +428,4 @@ class QueryModelResource(ModelResource): queryset = self.queryset if self.queryset else self.model.objects.all() return queryset.filer(**kwargs) - post = put = delete = http_method_not_allowed \ No newline at end of file + post = put = delete = None \ No newline at end of file diff --git a/djangorestframework/resource.py b/djangorestframework/resource.py index 65aa09c64..fbf51cfcb 100644 --- a/djangorestframework/resource.py +++ b/djangorestframework/resource.py @@ -41,7 +41,7 @@ class Resource(RequestMixin, ResponseMixin, AuthMixin, View): authenticators.BasicAuthenticator ) # List of all permissions required to access the resource - permissions = ( permissions.DeleteMePermission, ) + permissions = () # Optional form for input validation and presentation of HTML formatted responses. form = None @@ -54,7 +54,7 @@ class Resource(RequestMixin, ResponseMixin, AuthMixin, View): @property def allowed_methods(self): - return [method.upper() for method in self.http_method_names if hasattr(self, method)] + return [method.upper() for method in self.http_method_names if getattr(self, method, None)] def http_method_not_allowed(self, request, *args, **kwargs): """Return an HTTP 405 error if an operation is called which does not have a handler method.""" @@ -96,6 +96,9 @@ class Resource(RequestMixin, ResponseMixin, AuthMixin, View): # Get the appropriate handler method if self.method.lower() in self.http_method_names: handler = getattr(self, self.method.lower(), self.http_method_not_allowed) + # If a previously defined method has been disabled + if handler is None: + handler = self.http_method_not_allowed else: handler = self.http_method_not_allowed @@ -125,3 +128,4 @@ class Resource(RequestMixin, ResponseMixin, AuthMixin, View): return self.emit(response) + diff --git a/djangorestframework/tests/authentication.py b/djangorestframework/tests/authentication.py index 723005069..f2c249a6d 100644 --- a/djangorestframework/tests/authentication.py +++ b/djangorestframework/tests/authentication.py @@ -7,11 +7,13 @@ from django.utils import simplejson as json from djangorestframework.compat import RequestFactory from djangorestframework.resource import Resource +from djangorestframework import permissions import base64 class MockResource(Resource): + permissions = ( permissions.IsAuthenticated, ) def post(self, request): return {'a':1, 'b':2, 'c':3} diff --git a/examples/blogpost/views.py b/examples/blogpost/views.py index 59a3fb9ff..9e07aa8ab 100644 --- a/examples/blogpost/views.py +++ b/examples/blogpost/views.py @@ -8,25 +8,21 @@ MAX_POSTS = 10 class BlogPosts(RootModelResource): """A resource with which lists all existing blog posts and creates new blog posts.""" - 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.""" - anon_allowed_methods = allowed_methods = ('GET', 'PUT', 'DELETE') model = models.BlogPost fields = BLOG_POST_FIELDS 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.""" - anon_allowed_methods = allowed_methods = ('GET', 'POST',) model = models.Comment fields = COMMENT_FIELDS class CommentInstance(ModelResource): """A resource which represents a single comment.""" - anon_allowed_methods = allowed_methods = ('GET', 'PUT', 'DELETE') model = models.Comment fields = COMMENT_FIELDS diff --git a/examples/mixin/urls.py b/examples/mixin/urls.py index 050092845..96b630e38 100644 --- a/examples/mixin/urls.py +++ b/examples/mixin/urls.py @@ -1,12 +1,13 @@ 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.mixins import ResponseMixin +from djangorestframework.emitters import 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): +class ExampleView(ResponseMixin, 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 diff --git a/examples/modelresourceexample/views.py b/examples/modelresourceexample/views.py index e912c019d..07f50b653 100644 --- a/examples/modelresourceexample/views.py +++ b/examples/modelresourceexample/views.py @@ -7,12 +7,10 @@ class MyModelRootResource(RootModelResource): """A create/list resource for MyModel. Available for both authenticated and anonymous access for the purposes of the sandbox.""" model = MyModel - allowed_methods = anon_allowed_methods = ('GET', 'POST') fields = FIELDS class MyModelResource(ModelResource): """A read/update/delete resource for MyModel. Available for both authenticated and anonymous access for the purposes of the sandbox.""" model = MyModel - allowed_methods = anon_allowed_methods = ('GET', 'PUT', 'DELETE') fields = FIELDS diff --git a/examples/pygments_api/views.py b/examples/pygments_api/views.py index 6fb9217aa..f1a89702f 100644 --- a/examples/pygments_api/views.py +++ b/examples/pygments_api/views.py @@ -41,26 +41,25 @@ class PygmentsRoot(Resource): """This example demonstrates a simple RESTful Web API aound the awesome pygments library. This top level resource is used to create highlighted code snippets, and to list all the existing code snippets.""" form = PygmentsForm - allowed_methods = anon_allowed_methods = ('GET', 'POST',) - def get(self, request, auth): + def get(self, request): """Return a list of all currently existing snippets.""" unique_ids = [os.path.split(f)[1] for f in list_dir_sorted_by_ctime(HIGHLIGHTED_CODE_DIR)] return [reverse('pygments-instance', args=[unique_id]) for unique_id in unique_ids] - def post(self, request, auth, content): + def post(self, request): """Create a new highlighed snippet and return it's location. For the purposes of the sandbox example, also ensure we delete the oldest snippets if we have > MAX_FILES.""" unique_id = str(uuid.uuid1()) pathname = os.path.join(HIGHLIGHTED_CODE_DIR, unique_id) - lexer = get_lexer_by_name(content['lexer']) - linenos = 'table' if content['linenos'] else False - options = {'title': content['title']} if content['title'] else {} - formatter = HtmlFormatter(style=content['style'], linenos=linenos, full=True, **options) + lexer = get_lexer_by_name(self.CONTENT['lexer']) + linenos = 'table' if self.CONTENT['linenos'] else False + options = {'title': self.CONTENT['title']} if self.CONTENT['title'] else {} + formatter = HtmlFormatter(style=self.CONTENT['style'], linenos=linenos, full=True, **options) with open(pathname, 'w') as outfile: - highlight(content['code'], lexer, formatter, outfile) + highlight(self.CONTENT['code'], lexer, formatter, outfile) remove_oldest_files(HIGHLIGHTED_CODE_DIR, MAX_FILES) @@ -70,20 +69,19 @@ class PygmentsRoot(Resource): class PygmentsInstance(Resource): """Simply return the stored highlighted HTML file with the correct mime type. This Resource only emits HTML and uses a standard HTML emitter rather than the emitters.DocumentingHTMLEmitter class.""" - allowed_methods = anon_allowed_methods = ('GET',) emitters = (HTMLEmitter,) - def get(self, request, auth, unique_id): + def get(self, request, unique_id): """Return the highlighted snippet.""" pathname = os.path.join(HIGHLIGHTED_CODE_DIR, unique_id) if not os.path.exists(pathname): - return Resource(status.HTTP_404_NOT_FOUND) + return Response(status.HTTP_404_NOT_FOUND) return open(pathname, 'r').read() - def delete(self, request, auth, unique_id): + def delete(self, request, unique_id): """Delete the highlighted snippet.""" pathname = os.path.join(HIGHLIGHTED_CODE_DIR, unique_id) if not os.path.exists(pathname): - return Resource(status.HTTP_404_NOT_FOUND) + return Response(status.HTTP_404_NOT_FOUND) return os.remove(pathname) diff --git a/examples/resourceexample/views.py b/examples/resourceexample/views.py index 41d2e5c5c..911fd467b 100644 --- a/examples/resourceexample/views.py +++ b/examples/resourceexample/views.py @@ -8,24 +8,22 @@ from resourceexample.forms import MyForm class ExampleResource(Resource): """A basic read-only resource that points to 3 other resources.""" - allowed_methods = anon_allowed_methods = ('GET',) - def get(self, request, auth): + def get(self, request): 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.""" - allowed_methods = anon_allowed_methods = ('GET', 'POST') form = MyForm # Optional form validation on input (Applies in this case the POST method, but can also apply to PUT) - def get(self, request, auth, num): + def get(self, request, num): """Handle GET requests""" if int(num) > 2: return Response(status.HTTP_404_NOT_FOUND) return "GET request to AnotherExampleResource %s" % num - def post(self, request, auth, content, num): + def post(self, request, num): """Handle POST requests""" if int(num) > 2: return Response(status.HTTP_404_NOT_FOUND) - return "POST request to AnotherExampleResource %s, with content: %s" % (num, repr(content)) + return "POST request to AnotherExampleResource %s, with content: %s" % (num, repr(self.CONTENT)) diff --git a/examples/sandbox/views.py b/examples/sandbox/views.py index 561bdb1d7..5b84e8e42 100644 --- a/examples/sandbox/views.py +++ b/examples/sandbox/views.py @@ -24,9 +24,8 @@ class Sandbox(Resource): 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): + def get(self, request): 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')}, From 659898ffaf24f74b62e73c487cd81bad21904790 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 27 Apr 2011 18:08:32 +0100 Subject: [PATCH 23/70] Inital pass at generic permissions, throttling etc. --- djangorestframework/permissions.py | 74 +++++++++++++++++++++++++ djangorestframework/tests/throttling.py | 38 +++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 djangorestframework/permissions.py create mode 100644 djangorestframework/tests/throttling.py diff --git a/djangorestframework/permissions.py b/djangorestframework/permissions.py new file mode 100644 index 000000000..98d4b0be3 --- /dev/null +++ b/djangorestframework/permissions.py @@ -0,0 +1,74 @@ +from django.core.cache import cache +from djangorestframework import status +import time + + +class BasePermission(object): + """A base class from which all permission classes should inherit.""" + def __init__(self, view): + self.view = view + + def has_permission(self, auth): + return True + +class IsAuthenticated(BasePermission): + """""" + def has_permission(self, auth): + return auth is not None and auth.is_authenticated() + +#class IsUser(BasePermission): +# """The request has authenticated as a user.""" +# def has_permission(self, auth): +# pass +# +#class IsAdminUser(): +# """The request has authenticated as an admin user.""" +# def has_permission(self, auth): +# pass +# +#class IsUserOrIsAnonReadOnly(BasePermission): +# """The request has authenticated as a user, or is a read-only request.""" +# def has_permission(self, auth): +# pass +# +#class OAuthTokenInScope(BasePermission): +# def has_permission(self, auth): +# pass +# +#class UserHasModelPermissions(BasePermission): +# def has_permission(self, auth): +# pass + + +class Throttling(BasePermission): + """Rate throttling of requests on a per-user basis. + + The rate is set by a 'throttle' attribute on the view class. + The attribute is a two tuple of the form (number of requests, duration in seconds). + + The user's id will be used as a unique identifier if the user is authenticated. + For anonymous requests, the IP address of the client will be used. + + Previous request information used for throttling is stored in the cache. + """ + def has_permission(self, auth): + (num_requests, duration) = getattr(self.view, 'throttle', (0, 0)) + + if auth.is_authenticated(): + ident = str(auth) + else: + ident = self.view.request.META.get('REMOTE_ADDR', None) + + key = 'throttle_%s' % ident + history = cache.get(key, []) + now = time.time() + + # Drop any requests from the history which have now passed the throttle duration + while history and history[0] < now - duration: + history.pop() + + if len(history) >= num_requests: + raise ErrorResponse(status.HTTP_503_SERVICE_UNAVAILABLE, {'detail': 'request was throttled'}) + + history.insert(0, now) + cache.set(key, history, duration) diff --git a/djangorestframework/tests/throttling.py b/djangorestframework/tests/throttling.py new file mode 100644 index 000000000..46383271f --- /dev/null +++ b/djangorestframework/tests/throttling.py @@ -0,0 +1,38 @@ +from django.conf.urls.defaults import patterns +from django.test import TestCase +from django.utils import simplejson as json + +from djangorestframework.compat import RequestFactory +from djangorestframework.resource import Resource +from djangorestframework.permissions import Throttling + + +class MockResource(Resource): + permissions = ( Throttling, ) + throttle = (3, 1) # 3 requests per second + + def get(self, request): + return 'foo' + +urlpatterns = patterns('', + (r'^$', MockResource.as_view()), +) + + +#class ThrottlingTests(TestCase): +# """Basic authentication""" +# urls = 'djangorestframework.tests.throttling' +# +# def test_requests_are_throttled(self): +# """Ensure request rate is limited""" +# for dummy in range(3): +# response = self.client.get('/') +# response = self.client.get('/') +# +# def test_request_throttling_is_per_user(self): +# """Ensure request rate is only limited per user, not globally""" +# pass +# +# def test_request_throttling_expires(self): +# """Ensure request rate is limited for a limited duration only""" +# pass From b18302586c0eeea2e09c799544b9c0a855e11755 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 27 Apr 2011 18:36:43 +0100 Subject: [PATCH 24/70] Urg. Fix broken merging. --- djangorestframework/emitters.py | 140 -------------------- djangorestframework/parsers.py | 48 ------- djangorestframework/tests/authentication.py | 6 +- djangorestframework/tests/reverse.py | 4 - 4 files changed, 1 insertion(+), 197 deletions(-) diff --git a/djangorestframework/emitters.py b/djangorestframework/emitters.py index 0adddca95..2702758bc 100644 --- a/djangorestframework/emitters.py +++ b/djangorestframework/emitters.py @@ -7,151 +7,11 @@ from django import forms from django.conf import settings from django.template import RequestContext, loader from django.utils import simplejson as json -<<<<<<< local from django import forms -======= ->>>>>>> other -from djangorestframework.response import ErrorResponse -from djangorestframework.utils import dict2xml, url_resolves -from djangorestframework.markdownwrapper import apply_markdown -from djangorestframework.breadcrumbs import get_breadcrumbs -from djangorestframework.description import get_name, get_description -from djangorestframework import status - -from urllib import quote_plus -import string -import re -<<<<<<< local -======= from decimal import Decimal -_MSIE_USER_AGENT = re.compile(r'^Mozilla/[0-9]+\.[0-9]+ \([^)]*; MSIE [0-9]+\.[0-9]+[a-z]?;[^)]*\)(?!.* Opera )') - - -class EmitterMixin(object): - """Adds behaviour for pluggable Emitters to a :class:`.Resource` or Django :class:`View`. class. - - Default behaviour is to use standard HTTP Accept header content negotiation. - Also supports overidding the content type by specifying an _accept= parameter in the URL. - Ignores Accept headers from Internet Explorer user agents and uses a sensible browser Accept header instead.""" - - 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): - """Takes a :class:`Response` object and returns a Django :class:`HttpResponse`.""" - 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] ->>>>>>> other - - - # TODO: Rename verbose to something more appropriate # TODO: Maybe None could be handled more cleanly. It'd be nice if it was handled by default, # and only have an emitter output anything if it explicitly provides support for that. diff --git a/djangorestframework/parsers.py b/djangorestframework/parsers.py index 03f8bf8f7..96b29a66b 100644 --- a/djangorestframework/parsers.py +++ b/djangorestframework/parsers.py @@ -11,60 +11,12 @@ We need a method to be able to: from django.http.multipartparser import MultiPartParser as DjangoMPParser from django.utils import simplejson as json -<<<<<<< local from djangorestframework.response import ErrorResponse -======= -from djangorestframework.response import ResponseException ->>>>>>> other from djangorestframework import status from djangorestframework.utils import as_tuple from djangorestframework.mediatypes import MediaType from djangorestframework.compat import parse_qs -<<<<<<< local -======= -try: - from urlparse import parse_qs -except ImportError: - from cgi import parse_qs - -class ParserMixin(object): - parsers = () - - def parse(self, stream, content_type): - """ - Parse the request content. - - May raise a 415 ResponseException (Unsupported Media Type), - or a 400 ResponseException (Bad Request). - """ - parsers = as_tuple(self.parsers) - - parser = None - for parser_cls in parsers: - if parser_cls.handles(content_type): - parser = parser_cls(self) - break - - if parser is None: - raise ResponseException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, - {'error': 'Unsupported media type in request \'%s\'.' % - content_type.media_type}) - - return parser.parse(stream) - - @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] ->>>>>>> other - class BaseParser(object): """All parsers should extend BaseParser, specifying a media_type attribute, diff --git a/djangorestframework/tests/authentication.py b/djangorestframework/tests/authentication.py index a43a87b37..248bd87a6 100644 --- a/djangorestframework/tests/authentication.py +++ b/djangorestframework/tests/authentication.py @@ -1,12 +1,8 @@ from django.conf.urls.defaults import patterns -<<<<<<< local -from django.test import TestCase -from django.test import Client from django.contrib.auth.models import User from django.contrib.auth import login -======= from django.test import Client, TestCase ->>>>>>> other + from django.utils import simplejson as json from djangorestframework.compat import RequestFactory diff --git a/djangorestframework/tests/reverse.py b/djangorestframework/tests/reverse.py index 63e2080a2..49939d0e9 100644 --- a/djangorestframework/tests/reverse.py +++ b/djangorestframework/tests/reverse.py @@ -5,10 +5,6 @@ from django.utils import simplejson as json from djangorestframework.resource import Resource -<<<<<<< local - -======= ->>>>>>> other class MockResource(Resource): """Mock resource which simply returns a URL, so that we can ensure that reversed URLs are fully qualified""" From 5a59f339c1757767b136de33faa5b67a972141a1 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 27 Apr 2011 18:44:21 +0100 Subject: [PATCH 25/70] Urg. Fixing broken merge --- djangorestframework/authenticators.py | 2 +- djangorestframework/emitters.py | 8 ++- djangorestframework/resource.py | 98 ++++++++++++++------------- examples/blogpost/tests.py | 6 -- examples/pygments_api/tests.py | 6 -- 5 files changed, 59 insertions(+), 61 deletions(-) diff --git a/djangorestframework/authenticators.py b/djangorestframework/authenticators.py index 29fbb818b..74d9931ae 100644 --- a/djangorestframework/authenticators.py +++ b/djangorestframework/authenticators.py @@ -68,7 +68,7 @@ class UserLoggedInAuthenticator(BaseAuthenticator): if request.method.upper() == 'POST': # Temporarily replace request.POST with .RAW_CONTENT, # so that we use our more generic request parsing - request._post = self.mixin.RAW_CONTENT + request._post = self.view.RAW_CONTENT resp = CsrfViewMiddleware().process_view(request, None, (), {}) del(request._post) if resp is not None: # csrf failed diff --git a/djangorestframework/emitters.py b/djangorestframework/emitters.py index 2702758bc..d2b06c13b 100644 --- a/djangorestframework/emitters.py +++ b/djangorestframework/emitters.py @@ -9,8 +9,14 @@ from django.template import RequestContext, loader from django.utils import simplejson as json from django import forms -from decimal import Decimal +from djangorestframework.utils import dict2xml, url_resolves +from djangorestframework.markdownwrapper import apply_markdown +from djangorestframework.breadcrumbs import get_breadcrumbs +from djangorestframework.description import get_name, get_description +from djangorestframework import status +from decimal import Decimal +import string # TODO: Rename verbose to something more appropriate # TODO: Maybe None could be handled more cleanly. It'd be nice if it was handled by default, diff --git a/djangorestframework/resource.py b/djangorestframework/resource.py index fbf51cfcb..fdbce8b5b 100644 --- a/djangorestframework/resource.py +++ b/djangorestframework/resource.py @@ -77,55 +77,59 @@ class Resource(RequestMixin, ResponseMixin, AuthMixin, View): # all other authentication is CSRF exempt. @csrf_exempt def dispatch(self, request, *args, **kwargs): - self.request = request - self.args = args - self.kwargs = kwargs - - # 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) - try: - # If using a form POST with '_method'/'_content'/'_content_type' overrides, then alter - # self.method, self.content_type, self.RAW_CONTENT & self.CONTENT appropriately. - self.perform_form_overloading() - - # Authenticate and check request is has the relevant permissions - self.check_permissions() - - # Get the appropriate handler method - if self.method.lower() in self.http_method_names: - handler = getattr(self, self.method.lower(), self.http_method_not_allowed) - # If a previously defined method has been disabled - if handler is None: + self.request = request + self.args = args + self.kwargs = kwargs + + # 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) + + try: + # If using a form POST with '_method'/'_content'/'_content_type' overrides, then alter + # self.method, self.content_type, self.RAW_CONTENT & self.CONTENT appropriately. + self.perform_form_overloading() + + # Authenticate and check request is has the relevant permissions + self.check_permissions() + + # Get the appropriate handler method + if self.method.lower() in self.http_method_names: + handler = getattr(self, self.method.lower(), self.http_method_not_allowed) + # If a previously defined method has been disabled + if handler is None: + handler = self.http_method_not_allowed + else: handler = self.http_method_not_allowed - else: - handler = self.http_method_not_allowed - - response_obj = handler(request, *args, **kwargs) - - # Allow return value to be either Response, or an object, or None - if isinstance(response_obj, Response): - response = response_obj - elif response_obj is not None: - response = Response(status.HTTP_200_OK, response_obj) - else: - response = Response(status.HTTP_204_NO_CONTENT) - - # Pre-serialize filtering (eg filter complex objects into natively serializable types) - response.cleaned_content = self.cleanup_response(response.raw_content) - - except ErrorResponse, exc: - response = exc.response - - # Always add these headers. - # - # TODO - this isn't actually the correct way to set the vary header, - # also it's currently sub-obtimal for HTTP caching - need to sort that out. - response.headers['Allow'] = ', '.join(self.allowed_methods) - response.headers['Vary'] = 'Authenticate, Accept' - - return self.emit(response) + + response_obj = handler(request, *args, **kwargs) + + # Allow return value to be either Response, or an object, or None + if isinstance(response_obj, Response): + response = response_obj + elif response_obj is not None: + response = Response(status.HTTP_200_OK, response_obj) + else: + response = Response(status.HTTP_204_NO_CONTENT) + + # Pre-serialize filtering (eg filter complex objects into natively serializable types) + response.cleaned_content = self.cleanup_response(response.raw_content) + + except ErrorResponse, exc: + response = exc.response + + # Always add these headers. + # + # TODO - this isn't actually the correct way to set the vary header, + # also it's currently sub-obtimal for HTTP caching - need to sort that out. + response.headers['Allow'] = ', '.join(self.allowed_methods) + response.headers['Vary'] = 'Authenticate, Accept' + + return self.emit(response) + except: + import traceback + traceback.print_exc() diff --git a/examples/blogpost/tests.py b/examples/blogpost/tests.py index 494478d82..9b9a682fa 100644 --- a/examples/blogpost/tests.py +++ b/examples/blogpost/tests.py @@ -3,10 +3,7 @@ from django.core.urlresolvers import reverse from django.test import TestCase -<<<<<<< local -======= from django.core.urlresolvers import reverse ->>>>>>> other from django.utils import simplejson as json from djangorestframework.compat import RequestFactory @@ -170,10 +167,7 @@ class AllowedMethodsTests(TestCase): #above testcases need to probably moved to the core -<<<<<<< local -======= ->>>>>>> other class TestRotation(TestCase): """For the example the maximum amount of Blogposts is capped off at views.MAX_POSTS. diff --git a/examples/pygments_api/tests.py b/examples/pygments_api/tests.py index 766defc32..6eb69da5e 100644 --- a/examples/pygments_api/tests.py +++ b/examples/pygments_api/tests.py @@ -1,18 +1,12 @@ from django.test import TestCase from django.utils import simplejson as json -<<<<<<< local -======= ->>>>>>> other from djangorestframework.compat import RequestFactory from pygments_api import views import tempfile, shutil -<<<<<<< local -======= ->>>>>>> other class TestPygmentsExample(TestCase): From 5921e5c84e13cafe90061629262f12dfe742c07a Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 27 Apr 2011 18:53:54 +0100 Subject: [PATCH 26/70] Fix up ModelResource issues --- djangorestframework/emitters.py | 4 +++- djangorestframework/modelresource.py | 7 +++++-- djangorestframework/resource.py | 5 +---- examples/blogpost/views.py | 6 +++--- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/djangorestframework/emitters.py b/djangorestframework/emitters.py index d2b06c13b..87b3e94e2 100644 --- a/djangorestframework/emitters.py +++ b/djangorestframework/emitters.py @@ -15,8 +15,10 @@ from djangorestframework.breadcrumbs import get_breadcrumbs from djangorestframework.description import get_name, get_description from djangorestframework import status -from decimal import Decimal +from urllib import quote_plus import string +import re +from decimal import Decimal # TODO: Rename verbose to something more appropriate # TODO: Maybe None could be handled more cleanly. It'd be nice if it was handled by default, diff --git a/djangorestframework/modelresource.py b/djangorestframework/modelresource.py index 1afd7fa03..79505c6c9 100644 --- a/djangorestframework/modelresource.py +++ b/djangorestframework/modelresource.py @@ -408,6 +408,9 @@ class ModelResource(Resource): return +class InstanceModelResource(ModelResource): + http_method_names = ['get', 'put', 'delete', 'head', 'options', 'trace', 'patch'] # Bit of a hack, these - needs fixing. + class RootModelResource(ModelResource): """A Resource which provides default operations for list and create.""" queryset = None @@ -416,7 +419,7 @@ class RootModelResource(ModelResource): queryset = self.queryset if self.queryset else self.model.objects.all() return queryset.filter(**kwargs) - put = delete = None + http_method_names = ['get', 'post', 'head', 'options', 'trace', 'patch'] class QueryModelResource(ModelResource): """Resource with default operations for list. @@ -428,4 +431,4 @@ class QueryModelResource(ModelResource): queryset = self.queryset if self.queryset else self.model.objects.all() return queryset.filer(**kwargs) - post = put = delete = None \ No newline at end of file + http_method_names = ['get', 'head', 'options', 'trace', 'patch'] diff --git a/djangorestframework/resource.py b/djangorestframework/resource.py index fdbce8b5b..636fe0bab 100644 --- a/djangorestframework/resource.py +++ b/djangorestframework/resource.py @@ -54,7 +54,7 @@ class Resource(RequestMixin, ResponseMixin, AuthMixin, View): @property def allowed_methods(self): - return [method.upper() for method in self.http_method_names if getattr(self, method, None)] + return [method.upper() for method in self.http_method_names if hasattr(self, method)] def http_method_not_allowed(self, request, *args, **kwargs): """Return an HTTP 405 error if an operation is called which does not have a handler method.""" @@ -97,9 +97,6 @@ class Resource(RequestMixin, ResponseMixin, AuthMixin, View): # Get the appropriate handler method if self.method.lower() in self.http_method_names: handler = getattr(self, self.method.lower(), self.http_method_not_allowed) - # If a previously defined method has been disabled - if handler is None: - handler = self.http_method_not_allowed else: handler = self.http_method_not_allowed diff --git a/examples/blogpost/views.py b/examples/blogpost/views.py index 9e07aa8ab..e47f4a5b0 100644 --- a/examples/blogpost/views.py +++ b/examples/blogpost/views.py @@ -1,4 +1,4 @@ -from djangorestframework.modelresource import ModelResource, RootModelResource +from djangorestframework.modelresource import InstanceModelResource, RootModelResource from blogpost import models @@ -11,7 +11,7 @@ class BlogPosts(RootModelResource): model = models.BlogPost fields = BLOG_POST_FIELDS -class BlogPostInstance(ModelResource): +class BlogPostInstance(InstanceModelResource): """A resource which represents a single blog post.""" model = models.BlogPost fields = BLOG_POST_FIELDS @@ -21,7 +21,7 @@ class Comments(RootModelResource): model = models.Comment fields = COMMENT_FIELDS -class CommentInstance(ModelResource): +class CommentInstance(InstanceModelResource): """A resource which represents a single comment.""" model = models.Comment fields = COMMENT_FIELDS From 032cb7959265d7fe631dd3e0a6ff84da88dc71e5 Mon Sep 17 00:00:00 2001 From: markotibold Date: Wed, 27 Apr 2011 22:28:08 +0200 Subject: [PATCH 27/70] fix tiny spelling errors --- docs/examples/pygments.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/examples/pygments.rst b/docs/examples/pygments.rst index decc2f657..b4bc2c166 100644 --- a/docs/examples/pygments.rst +++ b/docs/examples/pygments.rst @@ -31,12 +31,12 @@ We'll need two resources: Form validation --------------- -We'll now add a form to specify what input fields are required when creating a new highlighed code snippet. This will include: +We'll now add a form to specify what input fields are required when creating a new highlighted code snippet. This will include: * The code text itself. * An optional title for the code. * A flag to determine if line numbers should be included. -* Which programming langauge to interpret the code snippet as. +* Which programming language to interpret the code snippet as. * Which output style to use for the highlighting. ``forms.py`` From 93aa065fa92f64472a3ee80564020a81776be742 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 28 Apr 2011 19:54:30 +0100 Subject: [PATCH 28/70] emitters -> renderers --- djangorestframework/mixins.py | 58 ++++++++++++------------ djangorestframework/modelresource.py | 6 +-- djangorestframework/resource.py | 22 ++++----- djangorestframework/tests/breadcrumbs.py | 2 +- djangorestframework/utils.py | 4 +- examples/mixin/urls.py | 6 +-- examples/pygments_api/views.py | 10 ++-- examples/sandbox/views.py | 6 +-- 8 files changed, 57 insertions(+), 57 deletions(-) diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 53262366a..43b33f504 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -233,8 +233,8 @@ class RequestMixin(object): @property def default_parser(self): - """Return the view's most preffered emitter. - (This has no behavioural effect, but is may be used by documenting emitters)""" + """Return the view's most preffered renderer. + (This has no behavioural effect, but is may be used by documenting renderers)""" return self.parsers[0] @@ -249,7 +249,7 @@ class RequestMixin(object): ########## ResponseMixin ########## class ResponseMixin(object): - """Adds behaviour for pluggable Emitters to a :class:`.Resource` or Django :class:`View`. class. + """Adds behaviour for pluggable Renderers to a :class:`.Resource` or Django :class:`View`. class. Default behaviour is to use standard HTTP Accept header content negotiation. Also supports overidding the content type by specifying an _accept= parameter in the URL. @@ -260,7 +260,7 @@ class ResponseMixin(object): #request = None #response = None - emitters = () + renderers = () #def render_to_response(self, obj): # if isinstance(obj, Response): @@ -285,21 +285,21 @@ class ResponseMixin(object): # return content - def emit(self, response): + def render(self, response): """Takes a :class:`Response` object and returns a Django :class:`HttpResponse`.""" self.response = response try: - emitter = self._determine_emitter(self.request) + renderer = self._determine_renderer(self.request) except ErrorResponse, exc: - emitter = self.default_emitter + renderer = self.default_renderer response = exc.response # Serialize the response content if response.has_content_body: - content = emitter(self).emit(output=response.cleaned_content) + content = renderer(self).render(output=response.cleaned_content) else: - content = emitter(self).emit() + content = renderer(self).render() # 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) @@ -307,16 +307,16 @@ class ResponseMixin(object): 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) + # TODO: Check if renderer.mimetype is underspecified, or if a content-type header has been set + resp = HttpResponse(content, mimetype=renderer.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, + def _determine_renderer(self, request): + """Return the appropriate renderer 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""" @@ -333,7 +333,7 @@ class ResponseMixin(object): accept_list = request.META["HTTP_ACCEPT"].split(',') else: # No accept header specified - return self.default_emitter + return self.default_renderer # Parse the accept header into a dict of {qvalue: set of media types} # We ignore mietype parameters @@ -363,34 +363,34 @@ class ResponseMixin(object): for accept_set in accept_sets: # Return any exact match - for emitter in self.emitters: - if emitter.media_type in accept_set: - return emitter + for renderer in self.renderers: + if renderer.media_type in accept_set: + return renderer # Return any subtype match - for emitter in self.emitters: - if emitter.media_type.split('/')[0] + '/*' in accept_set: - return emitter + for renderer in self.renderers: + if renderer.media_type.split('/')[0] + '/*' in accept_set: + return renderer # Return default if '*/*' in accept_set: - return self.default_emitter + return self.default_renderer raise ErrorResponse(status.HTTP_406_NOT_ACCEPTABLE, {'detail': 'Could not satisfy the client\'s Accept header', - 'available_types': self.emitted_media_types}) + 'available_types': self.renderted_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] + def renderted_media_types(self): + """Return an list of all the media types that this resource can render.""" + return [renderer.media_type for renderer in self.renderers] @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 default_renderer(self): + """Return the resource's most prefered renderer. + (This renderer is used if the client does not send and Accept: header, or sends Accept: */*)""" + return self.renderers[0] ########## Auth Mixin ########## diff --git a/djangorestframework/modelresource.py b/djangorestframework/modelresource.py index 79505c6c9..c61cc375e 100644 --- a/djangorestframework/modelresource.py +++ b/djangorestframework/modelresource.py @@ -49,7 +49,7 @@ class ModelResource(Resource): #def get_form(self, content=None): - # """Return a form that may be used in validation and/or rendering an html emitter""" + # """Return a form that may be used in validation and/or rendering an html renderer""" # if self.form: # return super(self.__class__, self).get_form(content) # @@ -121,8 +121,8 @@ class ModelResource(Resource): elif inspect.isfunction(thing): if not inspect.getargspec(thing)[0]: ret = _any(thing()) - elif hasattr(thing, '__emittable__'): - f = thing.__emittable__ + elif hasattr(thing, '__rendertable__'): + f = thing.__rendertable__ if inspect.ismethod(f) and len(inspect.getargspec(f)[0]) == 1: ret = _any(f()) else: diff --git a/djangorestframework/resource.py b/djangorestframework/resource.py index 636fe0bab..cb4d080c7 100644 --- a/djangorestframework/resource.py +++ b/djangorestframework/resource.py @@ -4,13 +4,13 @@ from django.views.decorators.csrf import csrf_exempt from djangorestframework.compat import View from djangorestframework.response import Response, ErrorResponse from djangorestframework.mixins import RequestMixin, ResponseMixin, AuthMixin -from djangorestframework import emitters, parsers, authenticators, permissions, validators, status +from djangorestframework import renderers, parsers, authenticators, permissions, validators, status # TODO: Figure how out references and named urls need to work nicely # TODO: POST on existing 404 URL, PUT on existing 404 URL # -# NEXT: Exceptions on func() -> 500, tracebacks emitted if settings.DEBUG +# NEXT: Exceptions on func() -> 500, tracebacks renderted if settings.DEBUG __all__ = ['Resource'] @@ -21,12 +21,12 @@ class Resource(RequestMixin, ResponseMixin, AuthMixin, View): http_method_names = ['get', 'post', 'put', 'delete', 'head', 'options', 'trace', 'patch'] - # List of emitters the resource can serialize the response with, ordered by preference. - emitters = ( emitters.JSONEmitter, - emitters.DocumentingHTMLEmitter, - emitters.DocumentingXHTMLEmitter, - emitters.DocumentingPlainTextEmitter, - emitters.XMLEmitter ) + # List of renderers the resource can serialize the response with, ordered by preference. + renderers = ( renderers.JSONRenderer, + renderers.DocumentingHTMLRenderer, + renderers.DocumentingXHTMLRenderer, + renderers.DocumentingPlainTextRenderer, + renderers.XMLRenderer ) # List of parsers the resource can parse the request with. parsers = ( parsers.JSONParser, @@ -48,7 +48,7 @@ class Resource(RequestMixin, ResponseMixin, AuthMixin, View): # 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. + # These are used for documentation in the standard html and text renderers. name = None description = None @@ -69,7 +69,7 @@ class Resource(RequestMixin, ResponseMixin, AuthMixin, View): 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.""" + the RendererMixin and Renderer classes.""" return data @@ -123,7 +123,7 @@ class Resource(RequestMixin, ResponseMixin, AuthMixin, View): response.headers['Allow'] = ', '.join(self.allowed_methods) response.headers['Vary'] = 'Authenticate, Accept' - return self.emit(response) + return self.render(response) except: import traceback traceback.print_exc() diff --git a/djangorestframework/tests/breadcrumbs.py b/djangorestframework/tests/breadcrumbs.py index cc0d283d6..724f2ff56 100644 --- a/djangorestframework/tests/breadcrumbs.py +++ b/djangorestframework/tests/breadcrumbs.py @@ -28,7 +28,7 @@ urlpatterns = patterns('', class BreadcrumbTests(TestCase): - """Tests the breadcrumb functionality used by the HTML emitter.""" + """Tests the breadcrumb functionality used by the HTML renderer.""" urls = 'djangorestframework.tests.breadcrumbs' diff --git a/djangorestframework/utils.py b/djangorestframework/utils.py index 8b12294c7..f60bdee4d 100644 --- a/djangorestframework/utils.py +++ b/djangorestframework/utils.py @@ -125,7 +125,7 @@ def xml2dict(input): # Piston: -class XMLEmitter(): +class XMLRenderer(): def _to_xml(self, xml, data): if isinstance(data, (list, tuple)): for item in data: @@ -156,4 +156,4 @@ class XMLEmitter(): return stream.getvalue() def dict2xml(input): - return XMLEmitter().dict2xml(input) + return XMLRenderer().dict2xml(input) diff --git a/examples/mixin/urls.py b/examples/mixin/urls.py index 96b630e38..f4300f419 100644 --- a/examples/mixin/urls.py +++ b/examples/mixin/urls.py @@ -1,6 +1,6 @@ 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.mixins import ResponseMixin -from djangorestframework.emitters import DEFAULT_EMITTERS +from djangorestframework.renderers import DEFAULT_RENDERERS from djangorestframework.response import Response from django.conf.urls.defaults import patterns, url @@ -9,8 +9,8 @@ from django.core.urlresolvers import reverse class ExampleView(ResponseMixin, 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 + Uses djangorestframework's RendererMixin to provide support for multiple output formats.""" + renderers = DEFAULT_RENDERERS def get(self, request): response = Response(200, {'description': 'Some example content', diff --git a/examples/pygments_api/views.py b/examples/pygments_api/views.py index 4e6d12307..278e82506 100644 --- a/examples/pygments_api/views.py +++ b/examples/pygments_api/views.py @@ -4,7 +4,7 @@ from django.core.urlresolvers import reverse from djangorestframework.resource import Resource from djangorestframework.response import Response -from djangorestframework.emitters import BaseEmitter +from djangorestframework.renderers import BaseRenderer from djangorestframework import status from pygments.formatters import HtmlFormatter @@ -32,8 +32,8 @@ def remove_oldest_files(dir, max_files): [os.remove(path) for path in list_dir_sorted_by_ctime(dir)[max_files:]] -class HTMLEmitter(BaseEmitter): - """Basic emitter which just returns the content without any further serialization.""" +class HTMLRenderer(BaseRenderer): + """Basic renderer which just returns the content without any further serialization.""" media_type = 'text/html' @@ -68,8 +68,8 @@ class PygmentsRoot(Resource): class PygmentsInstance(Resource): """Simply return the stored highlighted HTML file with the correct mime type. - This Resource only emits HTML and uses a standard HTML emitter rather than the emitters.DocumentingHTMLEmitter class.""" - emitters = (HTMLEmitter,) + This Resource only emits HTML and uses a standard HTML renderer rather than the renderers.DocumentingHTMLRenderer class.""" + renderers = (HTMLRenderer,) def get(self, request, unique_id): """Return the highlighted snippet.""" diff --git a/examples/sandbox/views.py b/examples/sandbox/views.py index 5b84e8e42..04e4da412 100644 --- a/examples/sandbox/views.py +++ b/examples/sandbox/views.py @@ -11,14 +11,14 @@ class Sandbox(Resource): 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) + bash: curl -X GET http://api.django-rest-framework.org/ # (Use default renderer) + bash: curl -X GET http://api.django-rest-framework.org/ -H 'Accept: text/plain' # (Use plaintext documentation renderer) 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). + 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 [RendererMixin](http://django-rest-framework.org/library/renderers.html). 4. A generic object store API. 5. A code highlighting API. 6. A blog posts and comments API. From b358fbdbe9cbd4ce644c4b2c7b9b4cec0811e14e Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 29 Apr 2011 14:32:56 +0100 Subject: [PATCH 29/70] More refactoring - move various less core stuff into utils etc --- .../{authenticators.py => authentication.py} | 10 +-- djangorestframework/compat.py | 66 ++++++++++++-- djangorestframework/markdownwrapper.py | 51 ----------- djangorestframework/mixins.py | 10 +-- djangorestframework/parsers.py | 2 +- .../{emitters.py => renderers.py} | 86 +++++++++---------- djangorestframework/resource.py | 6 +- .../templates/{emitter.html => renderer.html} | 2 +- .../templates/{emitter.txt => renderer.txt} | 0 djangorestframework/tests/breadcrumbs.py | 2 +- djangorestframework/tests/description.py | 4 +- djangorestframework/tests/emitters.py | 76 ---------------- djangorestframework/tests/parsers.py | 2 +- djangorestframework/tests/renderers.py | 76 ++++++++++++++++ .../{utils.py => utils/__init__.py} | 11 ++- .../{ => utils}/breadcrumbs.py | 3 +- .../{ => utils}/description.py | 0 djangorestframework/{ => utils}/mediatypes.py | 0 18 files changed, 203 insertions(+), 204 deletions(-) rename djangorestframework/{authenticators.py => authentication.py} (87%) delete mode 100644 djangorestframework/markdownwrapper.py rename djangorestframework/{emitters.py => renderers.py} (75%) rename djangorestframework/templates/{emitter.html => renderer.html} (98%) rename djangorestframework/templates/{emitter.txt => renderer.txt} (100%) delete mode 100644 djangorestframework/tests/emitters.py create mode 100644 djangorestframework/tests/renderers.py rename djangorestframework/{utils.py => utils/__init__.py} (98%) rename djangorestframework/{ => utils}/breadcrumbs.py (90%) rename djangorestframework/{ => utils}/description.py (100%) rename djangorestframework/{ => utils}/mediatypes.py (100%) diff --git a/djangorestframework/authenticators.py b/djangorestframework/authentication.py similarity index 87% rename from djangorestframework/authenticators.py rename to djangorestframework/authentication.py index 74d9931ae..894b34fca 100644 --- a/djangorestframework/authenticators.py +++ b/djangorestframework/authentication.py @@ -1,8 +1,8 @@ -"""The :mod:`authenticators` modules provides for pluggable authentication behaviour. +"""The :mod:`authentication` modules provides for pluggable authentication behaviour. Authentication behaviour is provided by adding the mixin class :class:`AuthenticatorMixin` to a :class:`.Resource` or Django :class:`View` class. -The set of authenticators which are use is then specified by setting the :attr:`authenticators` attribute on the class, and listing a set of authenticator classes. +The set of authentication which are use is then specified by setting the :attr:`authentication` attribute on the class, and listing a set of authentication classes. """ from django.contrib.auth import authenticate from django.middleware.csrf import CsrfViewMiddleware @@ -11,11 +11,11 @@ import base64 class BaseAuthenticator(object): - """All authenticators should extend BaseAuthenticator.""" + """All authentication should extend BaseAuthenticator.""" def __init__(self, view): - """Initialise the authenticator with the mixin instance as state, - in case the authenticator needs to access any metadata on the mixin object.""" + """Initialise the authentication with the mixin instance as state, + in case the authentication needs to access any metadata on the mixin object.""" self.view = view def authenticate(self, request): diff --git a/djangorestframework/compat.py b/djangorestframework/compat.py index 22b571861..98fbbb62c 100644 --- a/djangorestframework/compat.py +++ b/djangorestframework/compat.py @@ -1,9 +1,24 @@ """Compatability module to provide support for backwards compatability with older versions of django/python""" +# cStringIO only if it's available +try: + import cStringIO as StringIO +except ImportError: + import StringIO + + +# parse_qs +try: + # python >= ? + from urlparse import parse_qs +except ImportError: + # python <= ? + from cgi import parse_qs + + # 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 @@ -49,7 +64,7 @@ except ImportError: # django.views.generic.View (Django >= 1.3) try: from django.views.generic import View -except: +except ImportError: from django import http from django.utils.functional import update_wrapper # from django.utils.log import getLogger @@ -127,10 +142,47 @@ except: #) return http.HttpResponseNotAllowed(allowed_methods) -# parse_qs + try: - # python >= ? - from urlparse import parse_qs + import markdown + import re + + class CustomSetextHeaderProcessor(markdown.blockprocessors.BlockProcessor): + """Override markdown's SetextHeaderProcessor, so that ==== headers are

and ---- headers are

. + + We use

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

.""" + 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 ImportError: - # python <= ? - from cgi import parse_qs \ No newline at end of file + apply_markdown = None \ No newline at end of file diff --git a/djangorestframework/markdownwrapper.py b/djangorestframework/markdownwrapper.py deleted file mode 100644 index 70512440b..000000000 --- a/djangorestframework/markdownwrapper.py +++ /dev/null @@ -1,51 +0,0 @@ -"""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

and ---- headers are

. - - We use

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

.""" - 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 \ No newline at end of file diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 43b33f504..ebeee31a7 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -1,4 +1,4 @@ -from djangorestframework.mediatypes import MediaType +from djangorestframework.utils.mediatypes import MediaType from djangorestframework.utils import as_tuple, MSIE_USER_AGENT_REGEX from djangorestframework.response import ErrorResponse from djangorestframework.parsers import FormParser, MultipartParser @@ -397,7 +397,7 @@ class ResponseMixin(object): class AuthMixin(object): """Mixin class to provide authentication and permission checking.""" - authenticators = () + authentication = () permissions = () @property @@ -407,9 +407,9 @@ class AuthMixin(object): return self._auth def _authenticate(self): - for authenticator_cls in self.authenticators: - authenticator = authenticator_cls(self) - auth = authenticator.authenticate(self.request) + for authentication_cls in self.authentication: + authentication = authentication_cls(self) + auth = authentication.authenticate(self.request) if auth: return auth return None diff --git a/djangorestframework/parsers.py b/djangorestframework/parsers.py index 96b29a66b..6d6bd5cef 100644 --- a/djangorestframework/parsers.py +++ b/djangorestframework/parsers.py @@ -14,7 +14,7 @@ from django.utils import simplejson as json from djangorestframework.response import ErrorResponse from djangorestframework import status from djangorestframework.utils import as_tuple -from djangorestframework.mediatypes import MediaType +from djangorestframework.utils.mediatypes import MediaType from djangorestframework.compat import parse_qs diff --git a/djangorestframework/emitters.py b/djangorestframework/renderers.py similarity index 75% rename from djangorestframework/emitters.py rename to djangorestframework/renderers.py index 87b3e94e2..e53dc061a 100644 --- a/djangorestframework/emitters.py +++ b/djangorestframework/renderers.py @@ -1,7 +1,7 @@ -"""Emitters are used to serialize a Resource's output into specific media types. -django-rest-framework also provides HTML and PlainText emitters that help self-document the API, +"""Renderers are used to serialize a Resource's output into specific media types. +django-rest-framework also provides HTML and PlainText renderers that help self-document the API, by serializing the output along with documentation regarding the Resource, output status and headers, -and providing forms and links depending on the allowed methods, emitters and parsers on the Resource. +and providing forms and links depending on the allowed methods, renderers and parsers on the Resource. """ from django import forms from django.conf import settings @@ -10,9 +10,9 @@ from django.utils import simplejson as json from django import forms from djangorestframework.utils import dict2xml, url_resolves -from djangorestframework.markdownwrapper import apply_markdown -from djangorestframework.breadcrumbs import get_breadcrumbs -from djangorestframework.description import get_name, get_description +from djangorestframework.compat import apply_markdown +from djangorestframework.utils.breadcrumbs import get_breadcrumbs +from djangorestframework.utils.description import get_name, get_description from djangorestframework import status from urllib import quote_plus @@ -22,18 +22,18 @@ from decimal import Decimal # TODO: Rename verbose to something more appropriate # TODO: Maybe None could be handled more cleanly. It'd be nice if it was handled by default, -# and only have an emitter output anything if it explicitly provides support for that. +# and only have an renderer output anything if it explicitly provides support for that. -class BaseEmitter(object): - """All emitters must extend this class, set the media_type attribute, and - override the emit() function.""" +class BaseRenderer(object): + """All renderers must extend this class, set the media_type attribute, and + override the render() function.""" media_type = None def __init__(self, resource): self.resource = resource - def emit(self, output=None, verbose=False): - """By default emit simply returns the ouput as-is. + def render(self, output=None, verbose=False): + """By default render simply returns the ouput as-is. Override this method to provide for other behaviour.""" if output is None: return '' @@ -41,13 +41,13 @@ class BaseEmitter(object): return output -class TemplateEmitter(BaseEmitter): +class TemplateRenderer(BaseRenderer): """Provided for convienience. Emit the output by simply rendering it with the given template.""" media_type = None template = None - def emit(self, output=None, verbose=False): + def render(self, output=None, verbose=False): if output is None: return '' @@ -55,23 +55,23 @@ class TemplateEmitter(BaseEmitter): return self.template.render(context) -class DocumentingTemplateEmitter(BaseEmitter): - """Base class for emitters used to self-document the API. +class DocumentingTemplateRenderer(BaseRenderer): + """Base class for renderers used to self-document the API. Implementing classes should extend this class and set the template attribute.""" template = None def _get_content(self, resource, request, output): - """Get the content as if it had been emitted by a non-documenting emitter. + """Get the content as if it had been renderted by a non-documenting renderer. (Typically this will be the content as it would have been if the Resource had been requested with an 'Accept: */*' header, although with verbose style formatting if appropriate.)""" - # Find the first valid emitter and emit the content. (Don't use another documenting emitter.) - emitters = [emitter for emitter in resource.emitters if not isinstance(emitter, DocumentingTemplateEmitter)] - if not emitters: - return '[No emitters were found]' + # Find the first valid renderer and render the content. (Don't use another documenting renderer.) + renderers = [renderer for renderer in resource.renderers if not isinstance(renderer, DocumentingTemplateRenderer)] + if not renderers: + return '[No renderers were found]' - content = emitters[0](resource).emit(output, verbose=True) + content = renderers[0](resource).render(output, verbose=True) if not all(char in string.printable for char in content): return '[%d bytes of binary content]' @@ -146,7 +146,7 @@ class DocumentingTemplateEmitter(BaseEmitter): return GenericContentForm(resource) - def emit(self, output=None): + def render(self, output=None): content = self._get_content(self.resource, self.resource.request, output) form_instance = self._get_form_instance(self.resource) @@ -190,11 +190,11 @@ class DocumentingTemplateEmitter(BaseEmitter): return ret -class JSONEmitter(BaseEmitter): - """Emitter which serializes to JSON""" +class JSONRenderer(BaseRenderer): + """Renderer which serializes to JSON""" media_type = 'application/json' - def emit(self, output=None, verbose=False): + def render(self, output=None, verbose=False): if output is None: return '' if verbose: @@ -202,42 +202,42 @@ class JSONEmitter(BaseEmitter): return json.dumps(output) -class XMLEmitter(BaseEmitter): - """Emitter which serializes to XML.""" +class XMLRenderer(BaseRenderer): + """Renderer which serializes to XML.""" media_type = 'application/xml' - def emit(self, output=None, verbose=False): + def render(self, output=None, verbose=False): if output is None: return '' return dict2xml(output) -class DocumentingHTMLEmitter(DocumentingTemplateEmitter): - """Emitter which provides a browsable HTML interface for an API. +class DocumentingHTMLRenderer(DocumentingTemplateRenderer): + """Renderer which provides a browsable HTML interface for an API. See the examples listed in the django-rest-framework documentation to see this in actions.""" media_type = 'text/html' - template = 'emitter.html' + template = 'renderer.html' -class DocumentingXHTMLEmitter(DocumentingTemplateEmitter): - """Identical to DocumentingHTMLEmitter, except with an xhtml media type. +class DocumentingXHTMLRenderer(DocumentingTemplateRenderer): + """Identical to DocumentingHTMLRenderer, except with an xhtml media type. We need this to be listed in preference to xml in order to return HTML to WebKit based browsers, given their Accept headers.""" media_type = 'application/xhtml+xml' - template = 'emitter.html' + template = 'renderer.html' -class DocumentingPlainTextEmitter(DocumentingTemplateEmitter): - """Emitter that serializes the output with the default emitter, but also provides plain-text +class DocumentingPlainTextRenderer(DocumentingTemplateRenderer): + """Renderer that serializes the output with the default renderer, but also provides plain-text doumentation of the returned status and headers, and of the resource's name and description. Useful for browsing an API with command line tools.""" media_type = 'text/plain' - template = 'emitter.txt' + template = 'renderer.txt' -DEFAULT_EMITTERS = ( JSONEmitter, - DocumentingHTMLEmitter, - DocumentingXHTMLEmitter, - DocumentingPlainTextEmitter, - XMLEmitter ) +DEFAULT_RENDERERS = ( JSONRenderer, + DocumentingHTMLRenderer, + DocumentingXHTMLRenderer, + DocumentingPlainTextRenderer, + XMLRenderer ) diff --git a/djangorestframework/resource.py b/djangorestframework/resource.py index cb4d080c7..7879da7ce 100644 --- a/djangorestframework/resource.py +++ b/djangorestframework/resource.py @@ -4,7 +4,7 @@ from django.views.decorators.csrf import csrf_exempt from djangorestframework.compat import View from djangorestframework.response import Response, ErrorResponse from djangorestframework.mixins import RequestMixin, ResponseMixin, AuthMixin -from djangorestframework import renderers, parsers, authenticators, permissions, validators, status +from djangorestframework import renderers, parsers, authentication, permissions, validators, status # TODO: Figure how out references and named urls need to work nicely @@ -37,8 +37,8 @@ class Resource(RequestMixin, ResponseMixin, AuthMixin, View): validators = ( validators.FormValidator, ) # List of all authenticating methods to attempt. - authenticators = ( authenticators.UserLoggedInAuthenticator, - authenticators.BasicAuthenticator ) + authentication = ( authentication.UserLoggedInAuthenticator, + authentication.BasicAuthenticator ) # List of all permissions required to access the resource permissions = () diff --git a/djangorestframework/templates/emitter.html b/djangorestframework/templates/renderer.html similarity index 98% rename from djangorestframework/templates/emitter.html rename to djangorestframework/templates/renderer.html index 1931ad39e..105ea0a2d 100644 --- a/djangorestframework/templates/emitter.html +++ b/djangorestframework/templates/renderer.html @@ -48,7 +48,7 @@

GET {{ name }}

GET - {% for media_type in resource.emitted_media_types %} + {% for media_type in resource.renderted_media_types %} {% with resource.ACCEPT_QUERY_PARAM|add:"="|add:media_type as param %} [{{ media_type }}] {% endwith %} diff --git a/djangorestframework/templates/emitter.txt b/djangorestframework/templates/renderer.txt similarity index 100% rename from djangorestframework/templates/emitter.txt rename to djangorestframework/templates/renderer.txt diff --git a/djangorestframework/tests/breadcrumbs.py b/djangorestframework/tests/breadcrumbs.py index 724f2ff56..2f9a7e9d2 100644 --- a/djangorestframework/tests/breadcrumbs.py +++ b/djangorestframework/tests/breadcrumbs.py @@ -1,6 +1,6 @@ from django.conf.urls.defaults import patterns, url from django.test import TestCase -from djangorestframework.breadcrumbs import get_breadcrumbs +from djangorestframework.utils.breadcrumbs import get_breadcrumbs from djangorestframework.resource import Resource class Root(Resource): diff --git a/djangorestframework/tests/description.py b/djangorestframework/tests/description.py index 3e3f7b210..d34e2d110 100644 --- a/djangorestframework/tests/description.py +++ b/djangorestframework/tests/description.py @@ -1,7 +1,7 @@ from django.test import TestCase from djangorestframework.resource import Resource -from djangorestframework.markdownwrapper import apply_markdown -from djangorestframework.description import get_name, get_description +from djangorestframework.compat import apply_markdown +from djangorestframework.utils.description import get_name, get_description # We check that docstrings get nicely un-indented. DESCRIPTION = """an example docstring diff --git a/djangorestframework/tests/emitters.py b/djangorestframework/tests/emitters.py deleted file mode 100644 index 21a7eb95d..000000000 --- a/djangorestframework/tests/emitters.py +++ /dev/null @@ -1,76 +0,0 @@ -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 BaseEmitter -from djangorestframework.mixins import ResponseMixin -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(ResponseMixin, 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) \ No newline at end of file diff --git a/djangorestframework/tests/parsers.py b/djangorestframework/tests/parsers.py index 4753f6f39..00ebc812b 100644 --- a/djangorestframework/tests/parsers.py +++ b/djangorestframework/tests/parsers.py @@ -82,7 +82,7 @@ from django.test import TestCase from djangorestframework.compat import RequestFactory from djangorestframework.parsers import MultipartParser from djangorestframework.resource import Resource -from djangorestframework.mediatypes import MediaType +from djangorestframework.utils.mediatypes import MediaType from StringIO import StringIO def encode_multipart_formdata(fields, files): diff --git a/djangorestframework/tests/renderers.py b/djangorestframework/tests/renderers.py new file mode 100644 index 000000000..df0d9c8d4 --- /dev/null +++ b/djangorestframework/tests/renderers.py @@ -0,0 +1,76 @@ +from django.conf.urls.defaults import patterns, url +from django import http +from django.test import TestCase +from djangorestframework.compat import View +from djangorestframework.renderers import BaseRenderer +from djangorestframework.mixins import ResponseMixin +from djangorestframework.response import Response + +DUMMYSTATUS = 200 +DUMMYCONTENT = 'dummycontent' + +RENDERER_A_SERIALIZER = lambda x: 'Renderer A: %s' % x +RENDERER_B_SERIALIZER = lambda x: 'Renderer B: %s' % x + +class MockView(ResponseMixin, View): + def get(self, request): + response = Response(DUMMYSTATUS, DUMMYCONTENT) + return self.render(response) + +class RendererA(BaseRenderer): + media_type = 'mock/renderera' + + def render(self, output, verbose=False): + return RENDERER_A_SERIALIZER(output) + +class RendererB(BaseRenderer): + media_type = 'mock/rendererb' + + def render(self, output, verbose=False): + return RENDERER_B_SERIALIZER(output) + + +urlpatterns = patterns('', + url(r'^$', MockView.as_view(renderers=[RendererA, RendererB])), +) + + +class RendererIntegrationTests(TestCase): + """End-to-end testing of renderers using an RendererMixin on a generic view.""" + + urls = 'djangorestframework.tests.renderers' + + def test_default_renderer_serializes_content(self): + """If the Accept header is not set the default renderer should serialize the response.""" + resp = self.client.get('/') + self.assertEquals(resp['Content-Type'], RendererA.media_type) + self.assertEquals(resp.content, RENDERER_A_SERIALIZER(DUMMYCONTENT)) + self.assertEquals(resp.status_code, DUMMYSTATUS) + + def test_default_renderer_serializes_content_on_accept_any(self): + """If the Accept header is set to */* the default renderer should serialize the response.""" + resp = self.client.get('/', HTTP_ACCEPT='*/*') + self.assertEquals(resp['Content-Type'], RendererA.media_type) + self.assertEquals(resp.content, RENDERER_A_SERIALIZER(DUMMYCONTENT)) + self.assertEquals(resp.status_code, DUMMYSTATUS) + + def test_specified_renderer_serializes_content_default_case(self): + """If the Accept header is set the specified renderer should serialize the response. + (In this case we check that works for the default renderer)""" + resp = self.client.get('/', HTTP_ACCEPT=RendererA.media_type) + self.assertEquals(resp['Content-Type'], RendererA.media_type) + self.assertEquals(resp.content, RENDERER_A_SERIALIZER(DUMMYCONTENT)) + self.assertEquals(resp.status_code, DUMMYSTATUS) + + def test_specified_renderer_serializes_content_non_default_case(self): + """If the Accept header is set the specified renderer should serialize the response. + (In this case we check that works for a non-default renderer)""" + resp = self.client.get('/', HTTP_ACCEPT=RendererB.media_type) + self.assertEquals(resp['Content-Type'], RendererB.media_type) + self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT)) + self.assertEquals(resp.status_code, DUMMYSTATUS) + + def test_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) \ No newline at end of file diff --git a/djangorestframework/utils.py b/djangorestframework/utils/__init__.py similarity index 98% rename from djangorestframework/utils.py rename to djangorestframework/utils/__init__.py index f60bdee4d..9dc769be2 100644 --- a/djangorestframework/utils.py +++ b/djangorestframework/utils/__init__.py @@ -1,13 +1,12 @@ -import re -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 + +from djangorestframework.compat import StringIO + +import re +import xml.etree.ElementTree as ET #def admin_media_prefix(request): diff --git a/djangorestframework/breadcrumbs.py b/djangorestframework/utils/breadcrumbs.py similarity index 90% rename from djangorestframework/breadcrumbs.py rename to djangorestframework/utils/breadcrumbs.py index ba779dd01..1e604efce 100644 --- a/djangorestframework/breadcrumbs.py +++ b/djangorestframework/utils/breadcrumbs.py @@ -1,5 +1,5 @@ from django.core.urlresolvers import resolve -from djangorestframework.description import get_name +from djangorestframework.utils.description import get_name def get_breadcrumbs(url): """Given a url returns a list of breadcrumbs, which are each a tuple of (name, url).""" @@ -7,7 +7,6 @@ def get_breadcrumbs(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: diff --git a/djangorestframework/description.py b/djangorestframework/utils/description.py similarity index 100% rename from djangorestframework/description.py rename to djangorestframework/utils/description.py diff --git a/djangorestframework/mediatypes.py b/djangorestframework/utils/mediatypes.py similarity index 100% rename from djangorestframework/mediatypes.py rename to djangorestframework/utils/mediatypes.py From 8756664e064a18afc4713d921c318cd968f18433 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 2 May 2011 19:49:12 +0100 Subject: [PATCH 30/70] emitters -> renderers --- djangorestframework/mixins.py | 90 +++++++++++++++++++++++++- djangorestframework/modelresource.py | 94 +++------------------------- djangorestframework/renderers.py | 2 +- djangorestframework/resource.py | 10 ++- examples/blogpost/models.py | 5 +- examples/blogpost/views.py | 7 +-- examples/mixin/urls.py | 2 +- examples/pygments_api/views.py | 2 +- 8 files changed, 111 insertions(+), 101 deletions(-) diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index ebeee31a7..6bd83bfac 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -6,6 +6,7 @@ from djangorestframework import status from django.http import HttpResponse from django.http.multipartparser import LimitBytes # TODO: Use LimitedStream in compat + from StringIO import StringIO from decimal import Decimal import re @@ -233,7 +234,7 @@ class RequestMixin(object): @property def default_parser(self): - """Return the view's most preffered renderer. + """Return the view's most preferred renderer. (This has no behavioural effect, but is may be used by documenting renderers)""" return self.parsers[0] @@ -437,3 +438,90 @@ class AuthMixin(object): 'You may need to login or otherwise authenticate the request.'}) +########## Model Mixins ########## + +class ReadModelMixin(object): + """Behaviour to read a model instance on GET requests""" + def get(self, request, *args, **kwargs): + try: + if args: + # If we have any none kwargs then assume the last represents the primrary key + instance = self.model.objects.get(pk=args[-1], **kwargs) + else: + # Otherwise assume the kwargs uniquely identify the model + instance = self.model.objects.get(**kwargs) + except self.model.DoesNotExist: + raise ErrorResponse(status.HTTP_404_NOT_FOUND) + + return instance + + +class CreateModelMixin(object): + """Behaviour to create a model instance on POST requests""" + def post(self, request, *args, **kwargs): + # translated 'related_field' kwargs into 'related_field_id' + for related_name in [field.name for field in self.model._meta.fields if isinstance(field, RelatedField)]: + if kwargs.has_key(related_name): + kwargs[related_name + '_id'] = kwargs[related_name] + del kwargs[related_name] + + all_kw_args = dict(self.CONTENT.items() + kwargs.items()) + if args: + instance = self.model(pk=args[-1], **all_kw_args) + else: + instance = self.model(**all_kw_args) + instance.save() + headers = {} + if hasattr(instance, 'get_absolute_url'): + headers['Location'] = instance.get_absolute_url() + return Response(status.HTTP_201_CREATED, instance, headers) + + +class UpdateModelMixin(object): + """Behaviour to update a model instance on PUT requests""" + def put(self, request, *args, **kwargs): + # TODO: update on the url of a non-existing resource url doesn't work correctly at the moment - will end up with a new url + try: + if args: + # If we have any none kwargs then assume the last represents the primrary key + instance = self.model.objects.get(pk=args[-1], **kwargs) + else: + # Otherwise assume the kwargs uniquely identify the model + instance = self.model.objects.get(**kwargs) + + for (key, val) in self.CONTENT.items(): + setattr(instance, key, val) + except self.model.DoesNotExist: + instance = self.model(**self.CONTENT) + instance.save() + + instance.save() + return instance + + +class DeleteModelMixin(object): + """Behaviour to delete a model instance on DELETE requests""" + def delete(self, request, *args, **kwargs): + try: + if args: + # If we have any none kwargs then assume the last represents the primrary key + instance = self.model.objects.get(pk=args[-1], **kwargs) + else: + # Otherwise assume the kwargs uniquely identify the model + instance = self.model.objects.get(**kwargs) + except self.model.DoesNotExist: + raise ErrorResponse(status.HTTP_404_NOT_FOUND, None, {}) + + instance.delete() + return + + +class ListModelMixin(object): + """Behaviour to list a set of model instances on GET requests""" + queryset = None + + def get(self, request, *args, **kwargs): + queryset = self.queryset if self.queryset else self.model.objects.all() + return queryset.filter(**kwargs) + + diff --git a/djangorestframework/modelresource.py b/djangorestframework/modelresource.py index c61cc375e..c286e5866 100644 --- a/djangorestframework/modelresource.py +++ b/djangorestframework/modelresource.py @@ -341,94 +341,16 @@ class ModelResource(Resource): return _any(data, self.fields) - def get(self, request, *args, **kwargs): - try: - if args: - # If we have any none kwargs then assume the last represents the primrary key - instance = self.model.objects.get(pk=args[-1], **kwargs) - else: - # Otherwise assume the kwargs uniquely identify the model - instance = self.model.objects.get(**kwargs) - except self.model.DoesNotExist: - raise ErrorResponse(status.HTTP_404_NOT_FOUND) - - return instance - - def post(self, request, *args, **kwargs): - # TODO: test creation on a non-existing resource url - - # translated related_field into related_field_id - for related_name in [field.name for field in self.model._meta.fields if isinstance(field, RelatedField)]: - if kwargs.has_key(related_name): - kwargs[related_name + '_id'] = kwargs[related_name] - del kwargs[related_name] - - all_kw_args = dict(self.CONTENT.items() + kwargs.items()) - if args: - instance = self.model(pk=args[-1], **all_kw_args) - else: - instance = self.model(**all_kw_args) - instance.save() - headers = {} - if hasattr(instance, 'get_absolute_url'): - headers['Location'] = instance.get_absolute_url() - return Response(status.HTTP_201_CREATED, instance, headers) - - def put(self, request, *args, **kwargs): - # TODO: update on the url of a non-existing resource url doesn't work correctly at the moment - will end up with a new url - try: - if args: - # If we have any none kwargs then assume the last represents the primrary key - instance = self.model.objects.get(pk=args[-1], **kwargs) - else: - # Otherwise assume the kwargs uniquely identify the model - instance = self.model.objects.get(**kwargs) - - for (key, val) in self.CONTENT.items(): - setattr(instance, key, val) - except self.model.DoesNotExist: - instance = self.model(**self.CONTENT) - instance.save() - - instance.save() - return instance - - def delete(self, request, *args, **kwargs): - try: - if args: - # If we have any none kwargs then assume the last represents the primrary key - instance = self.model.objects.get(pk=args[-1], **kwargs) - else: - # Otherwise assume the kwargs uniquely identify the model - instance = self.model.objects.get(**kwargs) - except self.model.DoesNotExist: - raise ErrorResponse(status.HTTP_404_NOT_FOUND, None, {}) - - instance.delete() - return -class InstanceModelResource(ModelResource): - http_method_names = ['get', 'put', 'delete', 'head', 'options', 'trace', 'patch'] # Bit of a hack, these - needs fixing. +class InstanceModelResource(ReadModelMixin, UpdateModelMixin, DeleteModelMixin, ModelResource): + """A view which provides default operations for read/update/delete against a model instance.""" + pass -class RootModelResource(ModelResource): +class ListOrCreateModelResource(CreateModelMixin, ListModelMixin, ModelResource): """A Resource which provides default operations for list and create.""" - queryset = None + pass - def get(self, request, *args, **kwargs): - queryset = self.queryset if self.queryset else self.model.objects.all() - return queryset.filter(**kwargs) - - http_method_names = ['get', 'post', 'head', 'options', 'trace', 'patch'] - -class QueryModelResource(ModelResource): - """Resource with default operations for list. - TODO: provide filter/order/num_results/paging, and a create operation to create queries.""" - allowed_methods = ('GET',) - queryset = None - - def get(self, request, *args, **kwargs): - queryset = self.queryset if self.queryset else self.model.objects.all() - return queryset.filer(**kwargs) - - http_method_names = ['get', 'head', 'options', 'trace', 'patch'] +class ListModelResource(ListModelMixin, ModelResource): + """Resource with default operations for list.""" + pass \ No newline at end of file diff --git a/djangorestframework/renderers.py b/djangorestframework/renderers.py index e53dc061a..2a07894eb 100644 --- a/djangorestframework/renderers.py +++ b/djangorestframework/renderers.py @@ -43,7 +43,7 @@ class BaseRenderer(object): class TemplateRenderer(BaseRenderer): """Provided for convienience. - Emit the output by simply rendering it with the given template.""" + Render the output by simply rendering it with the given template.""" media_type = None template = None diff --git a/djangorestframework/resource.py b/djangorestframework/resource.py index 7879da7ce..e06873ae7 100644 --- a/djangorestframework/resource.py +++ b/djangorestframework/resource.py @@ -19,14 +19,12 @@ class Resource(RequestMixin, ResponseMixin, AuthMixin, View): """Handles incoming requests and maps them to REST operations. Performs request deserialization, response serialization, authentication and input validation.""" - http_method_names = ['get', 'post', 'put', 'delete', 'head', 'options', 'trace', 'patch'] - # List of renderers the resource can serialize the response with, ordered by preference. renderers = ( renderers.JSONRenderer, - renderers.DocumentingHTMLRenderer, - renderers.DocumentingXHTMLRenderer, - renderers.DocumentingPlainTextRenderer, - renderers.XMLRenderer ) + renderers.DocumentingHTMLRenderer, + renderers.DocumentingXHTMLRenderer, + renderers.DocumentingPlainTextRenderer, + renderers.XMLRenderer ) # List of parsers the resource can parse the request with. parsers = ( parsers.JSONParser, diff --git a/examples/blogpost/models.py b/examples/blogpost/models.py index 01a91e15b..3489c596b 100644 --- a/examples/blogpost/models.py +++ b/examples/blogpost/models.py @@ -12,6 +12,8 @@ RATING_CHOICES = ((0, 'Awful'), (3, 'Good'), (4, 'Excellent')) +MAX_POSTS = 10 + class BlogPost(models.Model): key = models.CharField(primary_key=True, max_length=64, default=uuid_str, editable=False) title = models.CharField(max_length=128) @@ -38,9 +40,10 @@ class BlogPost(models.Model): def save(self, *args, **kwargs): self.slug = slugify(self.title) super(self.__class__, self).save(*args, **kwargs) - for obj in self.__class__.objects.order_by('-pk')[10:]: + for obj in self.__class__.objects.order_by('-pk')[MAX_POSTS:]: obj.delete() + class Comment(models.Model): blogpost = models.ForeignKey(BlogPost, editable=False, related_name='comments') username = models.CharField(max_length=128) diff --git a/examples/blogpost/views.py b/examples/blogpost/views.py index e47f4a5b0..c4b54f734 100644 --- a/examples/blogpost/views.py +++ b/examples/blogpost/views.py @@ -1,12 +1,11 @@ -from djangorestframework.modelresource import InstanceModelResource, RootModelResource +from djangorestframework.modelresource import InstanceModelResource, ListOrCreateModelResource 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') -MAX_POSTS = 10 -class BlogPosts(RootModelResource): +class BlogPosts(ListOrCreateModelResource): """A resource with which lists all existing blog posts and creates new blog posts.""" model = models.BlogPost fields = BLOG_POST_FIELDS @@ -16,7 +15,7 @@ class BlogPostInstance(InstanceModelResource): model = models.BlogPost fields = BLOG_POST_FIELDS -class Comments(RootModelResource): +class Comments(ListOrCreateModelResource): """A resource which lists all existing comments for a given blog post, and creates new blog comments for a given blog post.""" model = models.Comment fields = COMMENT_FIELDS diff --git a/examples/mixin/urls.py b/examples/mixin/urls.py index f4300f419..1d25f6c7d 100644 --- a/examples/mixin/urls.py +++ b/examples/mixin/urls.py @@ -15,7 +15,7 @@ class ExampleView(ResponseMixin, View): def get(self, request): response = Response(200, {'description': 'Some example content', 'url': reverse('mixin-view')}) - return self.emit(response) + return self.render(response) urlpatterns = patterns('', diff --git a/examples/pygments_api/views.py b/examples/pygments_api/views.py index 278e82506..253b09070 100644 --- a/examples/pygments_api/views.py +++ b/examples/pygments_api/views.py @@ -68,7 +68,7 @@ class PygmentsRoot(Resource): class PygmentsInstance(Resource): """Simply return the stored highlighted HTML file with the correct mime type. - This Resource only emits HTML and uses a standard HTML renderer rather than the renderers.DocumentingHTMLRenderer class.""" + This Resource only renders HTML and uses a standard HTML renderer rather than the renderers.DocumentingHTMLRenderer class.""" renderers = (HTMLRenderer,) def get(self, request, unique_id): From d373b3a067796b8e181be9368fa24e89c572c45e Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 4 May 2011 09:21:17 +0100 Subject: [PATCH 31/70] Decouple views and resources --- djangorestframework/authentication.py | 6 +- djangorestframework/mixins.py | 78 ++-- djangorestframework/modelresource.py | 356 ------------------- djangorestframework/permissions.py | 6 + djangorestframework/renderers.py | 2 +- djangorestframework/resource.py | 353 ++++++++++++------ djangorestframework/response.py | 2 +- djangorestframework/templates/api_login.html | 2 +- djangorestframework/tests/accept.py | 10 +- djangorestframework/tests/authentication.py | 6 +- djangorestframework/tests/breadcrumbs.py | 12 +- djangorestframework/tests/description.py | 28 +- djangorestframework/tests/files.py | 6 +- djangorestframework/tests/parsers.py | 22 +- djangorestframework/tests/reverse.py | 8 +- djangorestframework/tests/throttling.py | 6 +- djangorestframework/tests/validators.py | 29 +- djangorestframework/tests/views.py | 2 +- djangorestframework/urls.py | 16 + djangorestframework/utils/staticviews.py | 65 ++++ djangorestframework/validators.py | 16 +- djangorestframework/views.py | 207 +++++++---- examples/modelresourceexample/views.py | 6 +- examples/urls.py | 15 +- 24 files changed, 582 insertions(+), 677 deletions(-) delete mode 100644 djangorestframework/modelresource.py create mode 100644 djangorestframework/urls.py create mode 100644 djangorestframework/utils/staticviews.py diff --git a/djangorestframework/authentication.py b/djangorestframework/authentication.py index 894b34fca..9dd5c958d 100644 --- a/djangorestframework/authentication.py +++ b/djangorestframework/authentication.py @@ -1,6 +1,6 @@ """The :mod:`authentication` modules provides for pluggable authentication behaviour. -Authentication behaviour is provided by adding the mixin class :class:`AuthenticatorMixin` to a :class:`.Resource` or Django :class:`View` class. +Authentication behaviour is provided by adding the mixin class :class:`AuthenticatorMixin` to a :class:`.BaseView` or Django :class:`View` class. The set of authentication which are use is then specified by setting the :attr:`authentication` attribute on the class, and listing a set of authentication classes. """ @@ -25,10 +25,10 @@ class BaseAuthenticator(object): 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 + The default permission checking on View will use the allowed_methods attribute for permissions if the authentication context is not None, and use anon_allowed_methods otherwise. - The authentication context is available to the method calls eg Resource.get(request) + The authentication context is available to the method calls eg View.get(request) by accessing self.auth in order to allow them to apply any more fine grained permission checking at the point the response is being generated. diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 6bd83bfac..467ce0e0d 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -1,3 +1,4 @@ +"""""" from djangorestframework.utils.mediatypes import MediaType from djangorestframework.utils import as_tuple, MSIE_USER_AGENT_REGEX from djangorestframework.response import ErrorResponse @@ -12,6 +13,14 @@ from decimal import Decimal import re +__all__ = ['RequestMixin', + 'ResponseMixin', + 'AuthMixin', + 'ReadModelMixin', + 'CreateModelMixin', + 'UpdateModelMixin', + 'DeleteModelMixin', + 'ListModelMixin'] ########## Request Mixin ########## @@ -250,7 +259,7 @@ class RequestMixin(object): ########## ResponseMixin ########## class ResponseMixin(object): - """Adds behaviour for pluggable Renderers to a :class:`.Resource` or Django :class:`View`. class. + """Adds behaviour for pluggable Renderers to a :class:`.BaseView` or Django :class:`View`. class. Default behaviour is to use standard HTTP Accept header content negotiation. Also supports overidding the content type by specifying an _accept= parameter in the URL. @@ -259,32 +268,8 @@ class ResponseMixin(object): ACCEPT_QUERY_PARAM = '_accept' # Allow override of Accept header in URL query params REWRITE_IE_ACCEPT_HEADER = True - #request = None - #response = None renderers = () - #def render_to_response(self, obj): - # if isinstance(obj, Response): - # response = obj - # elif response_obj is not None: - # response = Response(status.HTTP_200_OK, obj) - # else: - # response = Response(status.HTTP_204_NO_CONTENT) - - # response.cleaned_content = self._filter(response.raw_content) - - # self._render(response) - - - #def filter(self, content): - # """ - # Filter the response content. - # """ - # for filterer_cls in self.filterers: - # filterer = filterer_cls(self) - # content = filterer.filter(content) - # return content - def render(self, response): """Takes a :class:`Response` object and returns a Django :class:`HttpResponse`.""" @@ -318,7 +303,7 @@ class ResponseMixin(object): def _determine_renderer(self, request): """Return the appropriate renderer for the output, given the client's 'Accept' header, - and the content types that this Resource knows how to serve. + and the content types that this mixin knows how to serve. See: RFC 2616, Section 14 - http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html""" @@ -415,17 +400,6 @@ class AuthMixin(object): return auth return None - # TODO? - #@property - #def user(self): - # if not has_attr(self, '_user'): - # auth = self.auth - # if isinstance(auth, User...): - # self._user = auth - # else: - # self._user = getattr(auth, 'user', None) - # return self._user - def check_permissions(self): if not self.permissions: return @@ -443,14 +417,15 @@ class AuthMixin(object): class ReadModelMixin(object): """Behaviour to read a model instance on GET requests""" def get(self, request, *args, **kwargs): + model = self.resource.model try: if args: # If we have any none kwargs then assume the last represents the primrary key - instance = self.model.objects.get(pk=args[-1], **kwargs) + instance = model.objects.get(pk=args[-1], **kwargs) else: # Otherwise assume the kwargs uniquely identify the model - instance = self.model.objects.get(**kwargs) - except self.model.DoesNotExist: + instance = model.objects.get(**kwargs) + except model.DoesNotExist: raise ErrorResponse(status.HTTP_404_NOT_FOUND) return instance @@ -459,17 +434,18 @@ class ReadModelMixin(object): class CreateModelMixin(object): """Behaviour to create a model instance on POST requests""" def post(self, request, *args, **kwargs): + model = self.resource.model # translated 'related_field' kwargs into 'related_field_id' - for related_name in [field.name for field in self.model._meta.fields if isinstance(field, RelatedField)]: + for related_name in [field.name for field in model._meta.fields if isinstance(field, RelatedField)]: if kwargs.has_key(related_name): kwargs[related_name + '_id'] = kwargs[related_name] del kwargs[related_name] all_kw_args = dict(self.CONTENT.items() + kwargs.items()) if args: - instance = self.model(pk=args[-1], **all_kw_args) + instance = model(pk=args[-1], **all_kw_args) else: - instance = self.model(**all_kw_args) + instance = model(**all_kw_args) instance.save() headers = {} if hasattr(instance, 'get_absolute_url'): @@ -480,19 +456,20 @@ class CreateModelMixin(object): class UpdateModelMixin(object): """Behaviour to update a model instance on PUT requests""" def put(self, request, *args, **kwargs): + model = self.resource.model # TODO: update on the url of a non-existing resource url doesn't work correctly at the moment - will end up with a new url try: if args: # If we have any none kwargs then assume the last represents the primrary key - instance = self.model.objects.get(pk=args[-1], **kwargs) + instance = model.objects.get(pk=args[-1], **kwargs) else: # Otherwise assume the kwargs uniquely identify the model - instance = self.model.objects.get(**kwargs) + instance = model.objects.get(**kwargs) for (key, val) in self.CONTENT.items(): setattr(instance, key, val) - except self.model.DoesNotExist: - instance = self.model(**self.CONTENT) + except model.DoesNotExist: + instance = model(**self.CONTENT) instance.save() instance.save() @@ -502,14 +479,15 @@ class UpdateModelMixin(object): class DeleteModelMixin(object): """Behaviour to delete a model instance on DELETE requests""" def delete(self, request, *args, **kwargs): + model = self.resource.model try: if args: # If we have any none kwargs then assume the last represents the primrary key - instance = self.model.objects.get(pk=args[-1], **kwargs) + instance = model.objects.get(pk=args[-1], **kwargs) else: # Otherwise assume the kwargs uniquely identify the model - instance = self.model.objects.get(**kwargs) - except self.model.DoesNotExist: + instance = model.objects.get(**kwargs) + except model.DoesNotExist: raise ErrorResponse(status.HTTP_404_NOT_FOUND, None, {}) instance.delete() diff --git a/djangorestframework/modelresource.py b/djangorestframework/modelresource.py deleted file mode 100644 index c286e5866..000000000 --- a/djangorestframework/modelresource.py +++ /dev/null @@ -1,356 +0,0 @@ -from django.forms import ModelForm -from django.db.models import Model -from django.db.models.query import QuerySet -from django.db.models.fields.related import RelatedField - -from djangorestframework.response import Response, ErrorResponse -from djangorestframework.resource import Resource -from djangorestframework import status, validators - -import decimal -import inspect -import re - - -class ModelResource(Resource): - """A specialized type of Resource, for resources that map directly to a Django Model. - Useful things this provides: - - 0. Default input validation based on ModelForms. - 1. Nice serialization of returned Models and QuerySets. - 2. A default set of create/read/update/delete operations.""" - - # List of validators to validate, cleanup and type-ify the request content - validators = (validators.ModelFormValidator,) - - # The model attribute refers to the Django Model which this Resource maps to. - # (The Model's class, rather than an instance of the Model) - model = None - - # By default the set of returned fields will be the set of: - # - # 0. All the fields on the model, excluding 'id'. - # 1. All the properties on the model. - # 2. The absolute_url of the model, if a get_absolute_url method exists for the model. - # - # If you wish to override this behaviour, - # you should explicitly set the fields attribute on your class. - fields = None - - # By default the form used with be a ModelForm for self.model - # If you wish to override this behaviour or provide a sub-classed ModelForm - # you should explicitly set the form attribute on your class. - form = None - - # 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 - - - #def get_form(self, content=None): - # """Return a form that may be used in validation and/or rendering an html renderer""" - # 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_response(self, data): - """A munging of Piston's pre-serialization. Returns a dict""" - - def _any(thing, fields=()): - """ - Dispatch, all types are routed through here. - """ - ret = None - - if isinstance(thing, QuerySet): - ret = _qs(thing, fields=fields) - elif isinstance(thing, (tuple, list)): - ret = _list(thing) - elif isinstance(thing, dict): - ret = _dict(thing) - elif isinstance(thing, int): - ret = thing - elif isinstance(thing, bool): - ret = thing - elif isinstance(thing, type(None)): - ret = thing - elif isinstance(thing, decimal.Decimal): - ret = str(thing) - elif isinstance(thing, Model): - ret = _model(thing, fields=fields) - #elif isinstance(thing, HttpResponse): TRC - # raise HttpStatusCode(thing) - elif inspect.isfunction(thing): - if not inspect.getargspec(thing)[0]: - ret = _any(thing()) - elif hasattr(thing, '__rendertable__'): - f = thing.__rendertable__ - if inspect.ismethod(f) and len(inspect.getargspec(f)[0]) == 1: - ret = _any(f()) - else: - ret = unicode(thing) # TRC TODO: Change this back! - - return ret - - def _fk(data, field): - """ - Foreign keys. - """ - return _any(getattr(data, field.name)) - - def _related(data, fields=()): - """ - Foreign keys. - """ - return [ _model(m, fields) for m in data.iterator() ] - - def _m2m(data, field, fields=()): - """ - Many to many (re-route to `_model`.) - """ - return [ _model(m, fields) for m in getattr(data, field.name).iterator() ] - - - def _method_fields(data, fields): - if not data: - return { } - - has = dir(data) - ret = dict() - - for field in fields: - if field in has: - ret[field] = getattr(data, field) - - return ret - - def _model(data, fields=()): - """ - Models. Will respect the `fields` and/or - `exclude` on the handler (see `typemapper`.) - """ - ret = { } - #handler = self.in_typemapper(type(data), self.anonymous) # TRC - handler = None # TRC - get_absolute_url = False - - if handler or fields: - v = lambda f: getattr(data, f.attname) - - if not fields: - """ - Fields was not specified, try to find teh correct - version in the typemapper we were sent. - """ - mapped = self.in_typemapper(type(data), self.anonymous) - get_fields = set(mapped.fields) - exclude_fields = set(mapped.exclude).difference(get_fields) - - if not get_fields: - get_fields = set([ f.attname.replace("_id", "", 1) - for f in data._meta.fields ]) - - # sets can be negated. - for exclude in exclude_fields: - if isinstance(exclude, basestring): - get_fields.discard(exclude) - - elif isinstance(exclude, re._pattern_type): - for field in get_fields.copy(): - if exclude.match(field): - get_fields.discard(field) - - get_absolute_url = True - - else: - get_fields = set(fields) - if 'absolute_url' in get_fields: # MOVED (TRC) - get_absolute_url = True - - met_fields = _method_fields(handler, get_fields) # TRC - - for f in data._meta.local_fields: - if f.serialize and not any([ p in met_fields for p in [ f.attname, f.name ]]): - if not f.rel: - if f.attname in get_fields: - ret[f.attname] = _any(v(f)) - get_fields.remove(f.attname) - else: - if f.attname[:-3] in get_fields: - ret[f.name] = _fk(data, f) - get_fields.remove(f.name) - - for mf in data._meta.many_to_many: - if mf.serialize and mf.attname not in met_fields: - if mf.attname in get_fields: - ret[mf.name] = _m2m(data, mf) - get_fields.remove(mf.name) - - # try to get the remainder of fields - for maybe_field in get_fields: - - if isinstance(maybe_field, (list, tuple)): - model, fields = maybe_field - inst = getattr(data, model, None) - - if inst: - if hasattr(inst, 'all'): - ret[model] = _related(inst, fields) - elif callable(inst): - if len(inspect.getargspec(inst)[0]) == 1: - ret[model] = _any(inst(), fields) - else: - ret[model] = _model(inst, fields) - - elif maybe_field in met_fields: - # Overriding normal field which has a "resource method" - # so you can alter the contents of certain fields without - # using different names. - ret[maybe_field] = _any(met_fields[maybe_field](data)) - - else: - maybe = getattr(data, maybe_field, None) - if maybe: - if callable(maybe): - if len(inspect.getargspec(maybe)[0]) == 1: - ret[maybe_field] = _any(maybe()) - else: - ret[maybe_field] = _any(maybe) - else: - pass # TRC - #handler_f = getattr(handler or self.handler, maybe_field, None) - # - #if handler_f: - # ret[maybe_field] = _any(handler_f(data)) - - else: - # Add absolute_url if it exists - get_absolute_url = True - - # Add all the fields - for f in data._meta.fields: - if f.attname != 'id': - ret[f.attname] = _any(getattr(data, f.attname)) - - # Add all the propertiess - klass = data.__class__ - for attr in dir(klass): - if not attr.startswith('_') and not attr in ('pk','id') and isinstance(getattr(klass, attr, None), property): - #if attr.endswith('_url') or attr.endswith('_uri'): - # ret[attr] = self.make_absolute(_any(getattr(data, attr))) - #else: - ret[attr] = _any(getattr(data, attr)) - #fields = dir(data.__class__) + ret.keys() - #add_ons = [k for k in dir(data) if k not in fields and not k.startswith('_')] - #print add_ons - ###print dir(data.__class__) - #from django.db.models import Model - #model_fields = dir(Model) - - #for attr in dir(data): - ## #if attr.startswith('_'): - ## # continue - # if (attr in fields) and not (attr in model_fields) and not attr.startswith('_'): - # print attr, type(getattr(data, attr, None)), attr in fields, attr in model_fields - - #for k in add_ons: - # ret[k] = _any(getattr(data, k)) - - # TRC - # resouce uri - #if self.in_typemapper(type(data), self.anonymous): - # handler = self.in_typemapper(type(data), self.anonymous) - # if hasattr(handler, 'resource_uri'): - # url_id, fields = handler.resource_uri() - # ret['resource_uri'] = permalink( lambda: (url_id, - # (getattr(data, f) for f in fields) ) )() - - # TRC - #if hasattr(data, 'get_api_url') and 'resource_uri' not in ret: - # try: ret['resource_uri'] = data.get_api_url() - # except: pass - - # absolute uri - if hasattr(data, 'get_absolute_url') and get_absolute_url: - 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) - - return ret - - def _qs(data, fields=()): - """ - Querysets. - """ - return [ _any(v, fields) for v in data ] - - def _list(data): - """ - Lists. - """ - return [ _any(v) for v in data ] - - def _dict(data): - """ - Dictionaries. - """ - return dict([ (k, _any(v)) for k, v in data.iteritems() ]) - - # Kickstart the seralizin'. - return _any(data, self.fields) - - - - -class InstanceModelResource(ReadModelMixin, UpdateModelMixin, DeleteModelMixin, ModelResource): - """A view which provides default operations for read/update/delete against a model instance.""" - pass - -class ListOrCreateModelResource(CreateModelMixin, ListModelMixin, ModelResource): - """A Resource which provides default operations for list and create.""" - pass - -class ListModelResource(ListModelMixin, ModelResource): - """Resource with default operations for list.""" - pass \ No newline at end of file diff --git a/djangorestframework/permissions.py b/djangorestframework/permissions.py index 98d4b0be3..d98651e0c 100644 --- a/djangorestframework/permissions.py +++ b/djangorestframework/permissions.py @@ -11,6 +11,12 @@ class BasePermission(object): def has_permission(self, auth): return True + +class FullAnonAccess(BasePermission): + """""" + def has_permission(self, auth): + return True + class IsAuthenticated(BasePermission): """""" def has_permission(self, auth): diff --git a/djangorestframework/renderers.py b/djangorestframework/renderers.py index 2a07894eb..9e4e20533 100644 --- a/djangorestframework/renderers.py +++ b/djangorestframework/renderers.py @@ -1,4 +1,4 @@ -"""Renderers are used to serialize a Resource's output into specific media types. +"""Renderers are used to serialize a View's output into specific media types. django-rest-framework also provides HTML and PlainText renderers that help self-document the API, by serializing the output along with documentation regarding the Resource, output status and headers, and providing forms and links depending on the allowed methods, renderers and parsers on the Resource. diff --git a/djangorestframework/resource.py b/djangorestframework/resource.py index e06873ae7..044424987 100644 --- a/djangorestframework/resource.py +++ b/djangorestframework/resource.py @@ -1,130 +1,251 @@ -from django.core.urlresolvers import set_script_prefix -from django.views.decorators.csrf import csrf_exempt +from django.db.models import Model +from django.db.models.query import QuerySet +from django.db.models.fields.related import RelatedField -from djangorestframework.compat import View -from djangorestframework.response import Response, ErrorResponse -from djangorestframework.mixins import RequestMixin, ResponseMixin, AuthMixin -from djangorestframework import renderers, parsers, authentication, permissions, validators, status +import decimal +import inspect +import re -# TODO: Figure how out references and named urls need to work nicely -# TODO: POST on existing 404 URL, PUT on existing 404 URL -# -# NEXT: Exceptions on func() -> 500, tracebacks renderted if settings.DEBUG - -__all__ = ['Resource'] - - -class Resource(RequestMixin, ResponseMixin, AuthMixin, View): - """Handles incoming requests and maps them to REST operations. - Performs request deserialization, response serialization, authentication and input validation.""" - - # List of renderers the resource can serialize the response with, ordered by preference. - renderers = ( renderers.JSONRenderer, - renderers.DocumentingHTMLRenderer, - renderers.DocumentingXHTMLRenderer, - renderers.DocumentingPlainTextRenderer, - renderers.XMLRenderer ) - - # List of parsers the resource can parse the request with. - parsers = ( parsers.JSONParser, - parsers.FormParser, - parsers.MultipartParser ) - - # List of validators to validate, cleanup and normalize the request content - validators = ( validators.FormValidator, ) - - # List of all authenticating methods to attempt. - authentication = ( authentication.UserLoggedInAuthenticator, - authentication.BasicAuthenticator ) +class Resource(object): + """A Resource determines how an object maps to a serializable entity. + Objects that a resource can act on include plain Python object instances, Django Models, and Django QuerySets.""" - # List of all permissions required to access the resource - permissions = () + # The model attribute refers to the Django Model which this Resource maps to. + # (The Model's class, rather than an instance of the Model) + model = None + + # By default the set of returned fields will be the set of: + # + # 0. All the fields on the model, excluding 'id'. + # 1. All the properties on the model. + # 2. The absolute_url of the model, if a get_absolute_url method exists for the model. + # + # If you wish to override this behaviour, + # you should explicitly set the fields attribute on your class. + fields = None - # Optional form for input validation and presentation of HTML formatted responses. - form = None + @classmethod + def object_to_serializable(self, data): + """A (horrible) munging of Piston's pre-serialization. Returns a dict""" - # 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 renderers. - name = None - description = None + def _any(thing, fields=()): + """ + Dispatch, all types are routed through here. + """ + ret = None + + if isinstance(thing, QuerySet): + ret = _qs(thing, fields=fields) + elif isinstance(thing, (tuple, list)): + ret = _list(thing) + elif isinstance(thing, dict): + ret = _dict(thing) + elif isinstance(thing, int): + ret = thing + elif isinstance(thing, bool): + ret = thing + elif isinstance(thing, type(None)): + ret = thing + elif isinstance(thing, decimal.Decimal): + ret = str(thing) + elif isinstance(thing, Model): + ret = _model(thing, fields=fields) + #elif isinstance(thing, HttpResponse): TRC + # raise HttpStatusCode(thing) + elif inspect.isfunction(thing): + if not inspect.getargspec(thing)[0]: + ret = _any(thing()) + elif hasattr(thing, '__rendertable__'): + f = thing.__rendertable__ + if inspect.ismethod(f) and len(inspect.getargspec(f)[0]) == 1: + ret = _any(f()) + else: + ret = unicode(thing) # TRC TODO: Change this back! - @property - def allowed_methods(self): - return [method.upper() for method in self.http_method_names if hasattr(self, method)] + return ret - def http_method_not_allowed(self, request, *args, **kwargs): - """Return an HTTP 405 error if an operation is called which does not have a handler method.""" - raise ErrorResponse(status.HTTP_405_METHOD_NOT_ALLOWED, - {'detail': 'Method \'%s\' not allowed on this resource.' % self.method}) - - - def cleanup_response(self, data): - """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. + def _fk(data, field): + """ + Foreign keys. + """ + return _any(getattr(data, field.name)) + + def _related(data, fields=()): + """ + Foreign keys. + """ + return [ _model(m, fields) for m in data.iterator() ] + + def _m2m(data, field, fields=()): + """ + Many to many (re-route to `_model`.) + """ + return [ _model(m, fields) for m in getattr(data, field.name).iterator() ] - TODO: This is going to be removed. I think that the 'fields' behaviour is going to move into - the RendererMixin and Renderer classes.""" - return data + def _method_fields(data, fields): + if not data: + return { } + + has = dir(data) + ret = dict() + + for field in fields: + if field in has: + ret[field] = getattr(data, field) + + return ret - # Note: session based authentication is explicitly CSRF validated, - # all other authentication is CSRF exempt. - @csrf_exempt - def dispatch(self, request, *args, **kwargs): - try: - self.request = request - self.args = args - self.kwargs = kwargs - - # 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) - - try: - # If using a form POST with '_method'/'_content'/'_content_type' overrides, then alter - # self.method, self.content_type, self.RAW_CONTENT & self.CONTENT appropriately. - self.perform_form_overloading() - - # Authenticate and check request is has the relevant permissions - self.check_permissions() - - # Get the appropriate handler method - if self.method.lower() in self.http_method_names: - handler = getattr(self, self.method.lower(), self.http_method_not_allowed) - else: - handler = self.http_method_not_allowed - - response_obj = handler(request, *args, **kwargs) - - # Allow return value to be either Response, or an object, or None - if isinstance(response_obj, Response): - response = response_obj - elif response_obj is not None: - response = Response(status.HTTP_200_OK, response_obj) - else: - response = Response(status.HTTP_204_NO_CONTENT) - - # Pre-serialize filtering (eg filter complex objects into natively serializable types) - response.cleaned_content = self.cleanup_response(response.raw_content) - - except ErrorResponse, exc: - response = exc.response - - # Always add these headers. - # - # TODO - this isn't actually the correct way to set the vary header, - # also it's currently sub-obtimal for HTTP caching - need to sort that out. - response.headers['Allow'] = ', '.join(self.allowed_methods) - response.headers['Vary'] = 'Authenticate, Accept' - - return self.render(response) - except: - import traceback - traceback.print_exc() + def _model(data, fields=()): + """ + Models. Will respect the `fields` and/or + `exclude` on the handler (see `typemapper`.) + """ + ret = { } + #handler = self.in_typemapper(type(data), self.anonymous) # TRC + handler = None # TRC + get_absolute_url = False + + if fields: + v = lambda f: getattr(data, f.attname) + get_fields = set(fields) + if 'absolute_url' in get_fields: # MOVED (TRC) + get_absolute_url = True + met_fields = _method_fields(handler, get_fields) # TRC + + for f in data._meta.local_fields: + if f.serialize and not any([ p in met_fields for p in [ f.attname, f.name ]]): + if not f.rel: + if f.attname in get_fields: + ret[f.attname] = _any(v(f)) + get_fields.remove(f.attname) + else: + if f.attname[:-3] in get_fields: + ret[f.name] = _fk(data, f) + get_fields.remove(f.name) + + for mf in data._meta.many_to_many: + if mf.serialize and mf.attname not in met_fields: + if mf.attname in get_fields: + ret[mf.name] = _m2m(data, mf) + get_fields.remove(mf.name) + + # try to get the remainder of fields + for maybe_field in get_fields: + + if isinstance(maybe_field, (list, tuple)): + model, fields = maybe_field + inst = getattr(data, model, None) + + if inst: + if hasattr(inst, 'all'): + ret[model] = _related(inst, fields) + elif callable(inst): + if len(inspect.getargspec(inst)[0]) == 1: + ret[model] = _any(inst(), fields) + else: + ret[model] = _model(inst, fields) + + elif maybe_field in met_fields: + # Overriding normal field which has a "resource method" + # so you can alter the contents of certain fields without + # using different names. + ret[maybe_field] = _any(met_fields[maybe_field](data)) + + else: + maybe = getattr(data, maybe_field, None) + if maybe: + if callable(maybe): + if len(inspect.getargspec(maybe)[0]) == 1: + ret[maybe_field] = _any(maybe()) + else: + ret[maybe_field] = _any(maybe) + else: + pass # TRC + #handler_f = getattr(handler or self.handler, maybe_field, None) + # + #if handler_f: + # ret[maybe_field] = _any(handler_f(data)) + + else: + # Add absolute_url if it exists + get_absolute_url = True + + # Add all the fields + for f in data._meta.fields: + if f.attname != 'id': + ret[f.attname] = _any(getattr(data, f.attname)) + + # Add all the propertiess + klass = data.__class__ + for attr in dir(klass): + if not attr.startswith('_') and not attr in ('pk','id') and isinstance(getattr(klass, attr, None), property): + #if attr.endswith('_url') or attr.endswith('_uri'): + # ret[attr] = self.make_absolute(_any(getattr(data, attr))) + #else: + ret[attr] = _any(getattr(data, attr)) + #fields = dir(data.__class__) + ret.keys() + #add_ons = [k for k in dir(data) if k not in fields and not k.startswith('_')] + #print add_ons + ###print dir(data.__class__) + #from django.db.models import Model + #model_fields = dir(Model) + + #for attr in dir(data): + ## #if attr.startswith('_'): + ## # continue + # if (attr in fields) and not (attr in model_fields) and not attr.startswith('_'): + # print attr, type(getattr(data, attr, None)), attr in fields, attr in model_fields + + #for k in add_ons: + # ret[k] = _any(getattr(data, k)) + + # TRC + # resouce uri + #if self.in_typemapper(type(data), self.anonymous): + # handler = self.in_typemapper(type(data), self.anonymous) + # if hasattr(handler, 'resource_uri'): + # url_id, fields = handler.resource_uri() + # ret['resource_uri'] = permalink( lambda: (url_id, + # (getattr(data, f) for f in fields) ) )() + + # TRC + #if hasattr(data, 'get_api_url') and 'resource_uri' not in ret: + # try: ret['resource_uri'] = data.get_api_url() + # except: pass + + # absolute uri + if hasattr(data, 'get_absolute_url') and get_absolute_url: + 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) + + return ret + + def _qs(data, fields=()): + """ + Querysets. + """ + return [ _any(v, fields) for v in data ] + + def _list(data): + """ + Lists. + """ + return [ _any(v) for v in data ] + + def _dict(data): + """ + Dictionaries. + """ + return dict([ (k, _any(v)) for k, v in data.iteritems() ]) + + # Kickstart the seralizin'. + return _any(data, self.fields) diff --git a/djangorestframework/response.py b/djangorestframework/response.py index 545a58343..9b3c5851b 100644 --- a/djangorestframework/response.py +++ b/djangorestframework/response.py @@ -21,6 +21,6 @@ class Response(object): class ErrorResponse(BaseException): - """An exception representing an HttpResponse that should be returned immediatley.""" + """An exception representing an HttpResponse that should be returned immediately.""" def __init__(self, status, content=None, headers={}): self.response = Response(status, content=content, headers=headers) diff --git a/djangorestframework/templates/api_login.html b/djangorestframework/templates/api_login.html index ef383a0b5..9d06e8510 100644 --- a/djangorestframework/templates/api_login.html +++ b/djangorestframework/templates/api_login.html @@ -18,7 +18,7 @@
-
+ {% csrf_token %}
{{ form.username }} diff --git a/djangorestframework/tests/accept.py b/djangorestframework/tests/accept.py index b12dc7574..c5a3f69e8 100644 --- a/djangorestframework/tests/accept.py +++ b/djangorestframework/tests/accept.py @@ -1,6 +1,6 @@ from django.test import TestCase from djangorestframework.compat import RequestFactory -from djangorestframework.resource import Resource +from djangorestframework.views import BaseView # See: http://www.useragentstring.com/ @@ -19,15 +19,15 @@ class UserAgentMungingTest(TestCase): def setUp(self): - class MockResource(Resource): + class MockView(BaseView): permissions = () def get(self, request): return {'a':1, 'b':2, 'c':3} self.req = RequestFactory() - self.MockResource = MockResource - self.view = MockResource.as_view() + self.MockView = MockView + self.view = MockView.as_view() def test_munge_msie_accept_header(self): """Send MSIE user agent strings and ensure that we get an HTML response, @@ -42,7 +42,7 @@ class UserAgentMungingTest(TestCase): 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.""" - view = self.MockResource.as_view(REWRITE_IE_ACCEPT_HEADER=False) + view = self.MockView.as_view(REWRITE_IE_ACCEPT_HEADER=False) for user_agent in (MSIE_9_USER_AGENT, MSIE_8_USER_AGENT, diff --git a/djangorestframework/tests/authentication.py b/djangorestframework/tests/authentication.py index 248bd87a6..04ac471ad 100644 --- a/djangorestframework/tests/authentication.py +++ b/djangorestframework/tests/authentication.py @@ -6,19 +6,19 @@ from django.test import Client, TestCase from django.utils import simplejson as json from djangorestframework.compat import RequestFactory -from djangorestframework.resource import Resource +from djangorestframework.views import BaseView from djangorestframework import permissions import base64 -class MockResource(Resource): +class MockView(BaseView): permissions = ( permissions.IsAuthenticated, ) def post(self, request): return {'a':1, 'b':2, 'c':3} urlpatterns = patterns('', - (r'^$', MockResource.as_view()), + (r'^$', MockView.as_view()), ) diff --git a/djangorestframework/tests/breadcrumbs.py b/djangorestframework/tests/breadcrumbs.py index 2f9a7e9d2..1fd756347 100644 --- a/djangorestframework/tests/breadcrumbs.py +++ b/djangorestframework/tests/breadcrumbs.py @@ -1,21 +1,21 @@ from django.conf.urls.defaults import patterns, url from django.test import TestCase from djangorestframework.utils.breadcrumbs import get_breadcrumbs -from djangorestframework.resource import Resource +from djangorestframework.views import BaseView -class Root(Resource): +class Root(BaseView): pass -class ResourceRoot(Resource): +class ResourceRoot(BaseView): pass -class ResourceInstance(Resource): +class ResourceInstance(BaseView): pass -class NestedResourceRoot(Resource): +class NestedResourceRoot(BaseView): pass -class NestedResourceInstance(Resource): +class NestedResourceInstance(BaseView): pass urlpatterns = patterns('', diff --git a/djangorestframework/tests/description.py b/djangorestframework/tests/description.py index d34e2d110..d5a1102f7 100644 --- a/djangorestframework/tests/description.py +++ b/djangorestframework/tests/description.py @@ -1,5 +1,5 @@ from django.test import TestCase -from djangorestframework.resource import Resource +from djangorestframework.views import BaseView from djangorestframework.compat import apply_markdown from djangorestframework.utils.description import get_name, get_description @@ -32,23 +32,23 @@ MARKED_DOWN = """

an example docstring

hash style header

""" -class TestResourceNamesAndDescriptions(TestCase): +class TestViewNamesAndDescriptions(TestCase): def test_resource_name_uses_classname_by_default(self): """Ensure Resource names are based on the classname by default.""" - class MockResource(Resource): + class MockView(BaseView): pass - self.assertEquals(get_name(MockResource()), 'Mock Resource') + self.assertEquals(get_name(MockView()), 'Mock View') 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): + class MockView(BaseView): name = example - self.assertEquals(get_name(MockResource()), example) + self.assertEquals(get_name(MockView()), example) def test_resource_description_uses_docstring_by_default(self): """Ensure Resource names are based on the docstring by default.""" - class MockResource(Resource): + class MockView(BaseView): """an example docstring ==================== @@ -64,28 +64,28 @@ class TestResourceNamesAndDescriptions(TestCase): # hash style header #""" - self.assertEquals(get_description(MockResource()), DESCRIPTION) + self.assertEquals(get_description(MockView()), 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): + class MockView(BaseView): """docstring""" description = example - self.assertEquals(get_description(MockResource()), example) + self.assertEquals(get_description(MockView()), 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): + class MockView(BaseView): description = example - self.assertEquals(get_description(MockResource()), example) + self.assertEquals(get_description(MockView()), 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): + class MockView(BaseView): pass - self.assertEquals(get_description(MockResource()), '') + self.assertEquals(get_description(MockView()), '') def test_markdown(self): """Ensure markdown to HTML works as expected""" diff --git a/djangorestframework/tests/files.py b/djangorestframework/tests/files.py index 4dc3aa401..f0321cb32 100644 --- a/djangorestframework/tests/files.py +++ b/djangorestframework/tests/files.py @@ -1,7 +1,7 @@ from django.test import TestCase from django import forms from djangorestframework.compat import RequestFactory -from djangorestframework.resource import Resource +from djangorestframework.views import BaseView import StringIO class UploadFilesTests(TestCase): @@ -15,7 +15,7 @@ class UploadFilesTests(TestCase): class FileForm(forms.Form): file = forms.FileField - class MockResource(Resource): + class MockView(BaseView): permissions = () form = FileForm @@ -26,7 +26,7 @@ class UploadFilesTests(TestCase): file = StringIO.StringIO('stuff') file.name = 'stuff.txt' request = self.factory.post('/', {'file': file}) - view = MockResource.as_view() + view = MockView.as_view() response = view(request) self.assertEquals(response.content, '{"FILE_CONTENT": "stuff", "FILE_NAME": "stuff.txt"}') diff --git a/djangorestframework/tests/parsers.py b/djangorestframework/tests/parsers.py index 00ebc812b..049ac741e 100644 --- a/djangorestframework/tests/parsers.py +++ b/djangorestframework/tests/parsers.py @@ -2,12 +2,12 @@ .. >>> from djangorestframework.parsers import FormParser >>> from djangorestframework.compat import RequestFactory - >>> from djangorestframework.resource import Resource + >>> from djangorestframework.views import BaseView >>> from StringIO import StringIO >>> from urllib import urlencode >>> req = RequestFactory().get('/') - >>> some_resource = Resource() - >>> some_resource.request = req # Make as if this request had been dispatched + >>> some_view = BaseView() + >>> some_view.request = req # Make as if this request had been dispatched FormParser ============ @@ -24,7 +24,7 @@ Here is some example data, which would eventually be sent along with a post requ Default behaviour for :class:`parsers.FormParser`, is to return a single value for each parameter : - >>> FormParser(some_resource).parse(StringIO(inpt)) == {'key1': 'bla1', 'key2': 'blo1'} + >>> FormParser(some_view).parse(StringIO(inpt)) == {'key1': 'bla1', 'key2': 'blo1'} True However, you can customize this behaviour by subclassing :class:`parsers.FormParser`, and overriding :meth:`parsers.FormParser.is_a_list` : @@ -36,7 +36,7 @@ However, you can customize this behaviour by subclassing :class:`parsers.FormPar This new parser only flattens the lists of parameters that contain a single value. - >>> MyFormParser(some_resource).parse(StringIO(inpt)) == {'key1': 'bla1', 'key2': ['blo1', 'blo2']} + >>> MyFormParser(some_view).parse(StringIO(inpt)) == {'key1': 'bla1', 'key2': ['blo1', 'blo2']} True .. note:: The same functionality is available for :class:`parsers.MultipartParser`. @@ -61,7 +61,7 @@ The browsers usually strip the parameter completely. A hack to avoid this, and t :class:`parsers.FormParser` strips the values ``_empty`` from all the lists. - >>> MyFormParser(some_resource).parse(StringIO(inpt)) == {'key1': 'blo1'} + >>> MyFormParser(some_view).parse(StringIO(inpt)) == {'key1': 'blo1'} True Oh ... but wait a second, the parameter ``key2`` isn't even supposed to be a list, so the parser just stripped it. @@ -71,7 +71,7 @@ Oh ... but wait a second, the parameter ``key2`` isn't even supposed to be a lis ... def is_a_list(self, key, val_list): ... return key == 'key2' ... - >>> MyFormParser(some_resource).parse(StringIO(inpt)) == {'key1': 'blo1', 'key2': []} + >>> MyFormParser(some_view).parse(StringIO(inpt)) == {'key1': 'blo1', 'key2': []} True Better like that. Note that you can configure something else than ``_empty`` for the empty value by setting :attr:`parsers.FormParser.EMPTY_VALUE`. @@ -81,7 +81,7 @@ from tempfile import TemporaryFile from django.test import TestCase from djangorestframework.compat import RequestFactory from djangorestframework.parsers import MultipartParser -from djangorestframework.resource import Resource +from djangorestframework.views import BaseView from djangorestframework.utils.mediatypes import MediaType from StringIO import StringIO @@ -122,9 +122,9 @@ class TestMultipartParser(TestCase): def test_multipartparser(self): """Ensure that MultipartParser can parse multipart/form-data that contains a mix of several files and parameters.""" post_req = RequestFactory().post('/', self.body, content_type=self.content_type) - resource = Resource() - resource.request = post_req - parsed = MultipartParser(resource).parse(StringIO(self.body)) + view = BaseView() + view.request = post_req + parsed = MultipartParser(view).parse(StringIO(self.body)) self.assertEqual(parsed['key1'], 'val1') self.assertEqual(parsed.FILES['file1'].read(), 'blablabla') diff --git a/djangorestframework/tests/reverse.py b/djangorestframework/tests/reverse.py index 49939d0e9..7026d4a47 100644 --- a/djangorestframework/tests/reverse.py +++ b/djangorestframework/tests/reverse.py @@ -3,10 +3,10 @@ from django.core.urlresolvers import reverse from django.test import TestCase from django.utils import simplejson as json -from djangorestframework.resource import Resource +from djangorestframework.views import BaseView -class MockResource(Resource): +class MockView(BaseView): """Mock resource which simply returns a URL, so that we can ensure that reversed URLs are fully qualified""" permissions = () @@ -14,8 +14,8 @@ class MockResource(Resource): return reverse('another') urlpatterns = patterns('', - url(r'^$', MockResource.as_view()), - url(r'^another$', MockResource.as_view(), name='another'), + url(r'^$', MockView.as_view()), + url(r'^another$', MockView.as_view(), name='another'), ) diff --git a/djangorestframework/tests/throttling.py b/djangorestframework/tests/throttling.py index 46383271f..94d014288 100644 --- a/djangorestframework/tests/throttling.py +++ b/djangorestframework/tests/throttling.py @@ -3,11 +3,11 @@ from django.test import TestCase from django.utils import simplejson as json from djangorestframework.compat import RequestFactory -from djangorestframework.resource import Resource +from djangorestframework.views import BaseView from djangorestframework.permissions import Throttling -class MockResource(Resource): +class MockView(BaseView): permissions = ( Throttling, ) throttle = (3, 1) # 3 requests per second @@ -15,7 +15,7 @@ class MockResource(Resource): return 'foo' urlpatterns = patterns('', - (r'^$', MockResource.as_view()), + (r'^$', MockView.as_view()), ) diff --git a/djangorestframework/tests/validators.py b/djangorestframework/tests/validators.py index b6563db62..52a675d23 100644 --- a/djangorestframework/tests/validators.py +++ b/djangorestframework/tests/validators.py @@ -4,6 +4,8 @@ from django.test import TestCase from djangorestframework.compat import RequestFactory from djangorestframework.validators import BaseValidator, FormValidator, ModelFormValidator from djangorestframework.response import ErrorResponse +from djangorestframework.views import BaseView +from djangorestframework.resource import Resource class TestValidatorMixinInterfaces(TestCase): @@ -20,7 +22,7 @@ class TestDisabledValidations(TestCase): def test_disabled_form_validator_returns_content_unchanged(self): """If the view's form attribute is None then FormValidator(view).validate(content) should just return the content unmodified.""" - class DisabledFormView(object): + class DisabledFormView(BaseView): form = None view = DisabledFormView() @@ -30,7 +32,7 @@ class TestDisabledValidations(TestCase): def test_disabled_form_validator_get_bound_form_returns_none(self): """If the view's form attribute is None on then FormValidator(view).get_bound_form(content) should just return None.""" - class DisabledFormView(object): + class DisabledFormView(BaseView): form = None view = DisabledFormView() @@ -39,11 +41,10 @@ class TestDisabledValidations(TestCase): def test_disabled_model_form_validator_returns_content_unchanged(self): - """If the view's form and model attributes are None then + """If the view's form is None and does not have a Resource with a model set then ModelFormValidator(view).validate(content) should just return the content unmodified.""" - class DisabledModelFormView(object): + class DisabledModelFormView(BaseView): form = None - model = None view = DisabledModelFormView() content = {'qwerty':'uiop'} @@ -51,13 +52,12 @@ class TestDisabledValidations(TestCase): 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 DisabledModelFormView(object): - form = None + class DisabledModelFormView(BaseView): model = None view = DisabledModelFormView() content = {'qwerty':'uiop'} - self.assertEqual(ModelFormValidator(view).get_bound_form(content), None)# + self.assertEqual(ModelFormValidator(view).get_bound_form(content), None) class TestNonFieldErrors(TestCase): """Tests against form validation errors caused by non-field errors. (eg as might be caused by some custom form validation)""" @@ -84,7 +84,7 @@ class TestNonFieldErrors(TestCase): except ErrorResponse, exc: self.assertEqual(exc.response.raw_content, {'errors': [MockForm.ERROR_TEXT]}) else: - self.fail('ResourceException was not raised') #pragma: no cover + self.fail('ErrorResponse was not raised') #pragma: no cover class TestFormValidation(TestCase): @@ -95,11 +95,11 @@ class TestFormValidation(TestCase): class MockForm(forms.Form): qwerty = forms.CharField(required=True) - class MockFormView(object): + class MockFormView(BaseView): form = MockForm validators = (FormValidator,) - class MockModelFormView(object): + class MockModelFormView(BaseView): form = MockForm validators = (ModelFormValidator,) @@ -264,9 +264,12 @@ class TestModelFormValidator(TestCase): @property def readonly(self): return 'read only' - - class MockView(object): + + class MockResource(Resource): model = MockModel + + class MockView(BaseView): + resource = MockResource self.validator = ModelFormValidator(MockView) diff --git a/djangorestframework/tests/views.py b/djangorestframework/tests/views.py index 9e2e893fe..598712d2e 100644 --- a/djangorestframework/tests/views.py +++ b/djangorestframework/tests/views.py @@ -3,7 +3,7 @@ from django.test import TestCase from django.test import Client -urlpatterns = patterns('djangorestframework.views', +urlpatterns = patterns('djangorestframework.utils.staticviews', url(r'^robots.txt$', 'deny_robots'), url(r'^favicon.ico$', 'favicon'), url(r'^accounts/login$', 'api_login'), diff --git a/djangorestframework/urls.py b/djangorestframework/urls.py new file mode 100644 index 000000000..271b02159 --- /dev/null +++ b/djangorestframework/urls.py @@ -0,0 +1,16 @@ +from django.conf.urls.defaults import patterns +from django.conf import settings + +urlpatterns = patterns('djangorestframework.utils.staticviews', + (r'robots.txt', 'deny_robots'), + (r'^accounts/login/$', 'api_login'), + (r'^accounts/logout/$', 'api_logout'), +) + +# Only serve favicon in production because otherwise chrome users will pretty much +# permanantly have the django-rest-framework favicon whenever they navigate to +# 127.0.0.1:8000 or whatever, which gets annoying +if not settings.DEBUG: + urlpatterns += patterns('djangorestframework.utils.staticviews', + (r'favicon.ico', 'favicon'), + ) \ No newline at end of file diff --git a/djangorestframework/utils/staticviews.py b/djangorestframework/utils/staticviews.py new file mode 100644 index 000000000..de2cb5d8b --- /dev/null +++ b/djangorestframework/utils/staticviews.py @@ -0,0 +1,65 @@ +from django.contrib.auth.views import * +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) diff --git a/djangorestframework/validators.py b/djangorestframework/validators.py index c612de551..d806a6146 100644 --- a/djangorestframework/validators.py +++ b/djangorestframework/validators.py @@ -159,7 +159,7 @@ class ModelFormValidator(FormValidator): otherwise if model is set use that class to create a ModelForm, otherwise return None.""" form_cls = getattr(self.view, 'form', None) - model_cls = getattr(self.view, 'model', None) + model_cls = getattr(self.view.resource, 'model', None) if form_cls: # Use explict Form @@ -189,9 +189,10 @@ class ModelFormValidator(FormValidator): @property def _model_fields_set(self): """Return a set containing the names of validated fields on the model.""" - model = getattr(self.view, 'model', None) - fields = getattr(self.view, 'fields', self.fields) - exclude_fields = getattr(self.view, 'exclude_fields', self.exclude_fields) + resource = self.view.resource + model = getattr(resource, 'model', None) + fields = getattr(resource, 'fields', self.fields) + exclude_fields = getattr(resource, 'exclude_fields', self.exclude_fields) model_fields = set(field.name for field in model._meta.fields) @@ -203,9 +204,10 @@ class ModelFormValidator(FormValidator): @property def _property_fields_set(self): """Returns a set containing the names of validated properties on the model.""" - model = getattr(self.view, 'model', None) - fields = getattr(self.view, 'fields', self.fields) - exclude_fields = getattr(self.view, 'exclude_fields', self.exclude_fields) + resource = self.view.resource + model = getattr(resource, 'model', None) + fields = getattr(resource, 'fields', self.fields) + exclude_fields = getattr(resource, 'exclude_fields', self.exclude_fields) property_fields = set(attr for attr in dir(model) if isinstance(getattr(model, attr, None), property) diff --git a/djangorestframework/views.py b/djangorestframework/views.py index d50e126fc..dd30a0924 100644 --- a/djangorestframework/views.py +++ b/djangorestframework/views.py @@ -1,66 +1,147 @@ -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 +from django.core.urlresolvers import set_script_prefix +from django.views.decorators.csrf import csrf_exempt + +from djangorestframework.compat import View +from djangorestframework.response import Response, ErrorResponse +from djangorestframework.mixins import * +from djangorestframework import resource, renderers, parsers, authentication, permissions, validators, status + + +__all__ = ['BaseView', + 'ModelView', + 'InstanceModelView', + 'ListOrModelView', + 'ListOrCreateModelView'] + + + +class BaseView(RequestMixin, ResponseMixin, AuthMixin, View): + """Handles incoming requests and maps them to REST operations. + Performs request deserialization, response serialization, authentication and input validation.""" + + # Use the base resource by default + resource = resource.Resource + + # List of renderers the resource can serialize the response with, ordered by preference. + renderers = ( renderers.JSONRenderer, + renderers.DocumentingHTMLRenderer, + renderers.DocumentingXHTMLRenderer, + renderers.DocumentingPlainTextRenderer, + renderers.XMLRenderer ) + + # List of parsers the resource can parse the request with. + parsers = ( parsers.JSONParser, + parsers.FormParser, + parsers.MultipartParser ) + + # List of validators to validate, cleanup and normalize the request content + validators = ( validators.FormValidator, ) + + # List of all authenticating methods to attempt. + authentication = ( authentication.UserLoggedInAuthenticator, + authentication.BasicAuthenticator ) + + # List of all permissions that must be checked. + permissions = ( permissions.FullAnonAccess, ) + + # 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 renderers. + name = None + description = None + + @property + def allowed_methods(self): + return [method.upper() for method in self.http_method_names if hasattr(self, method)] + + def http_method_not_allowed(self, request, *args, **kwargs): + """Return an HTTP 405 error if an operation is called which does not have a handler method.""" + raise ErrorResponse(status.HTTP_405_METHOD_NOT_ALLOWED, + {'detail': 'Method \'%s\' not allowed on this resource.' % self.method}) + + + def cleanup_response(self, data): + """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. + + TODO: This is going to be removed. I think that the 'fields' behaviour is going to move into + the RendererMixin and Renderer classes.""" + return data + + + # Note: session based authentication is explicitly CSRF validated, + # all other authentication is CSRF exempt. + @csrf_exempt + def dispatch(self, request, *args, **kwargs): + self.request = request + self.args = args + self.kwargs = kwargs + + # 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) + + try: + # If using a form POST with '_method'/'_content'/'_content_type' overrides, then alter + # self.method, self.content_type, self.RAW_CONTENT & self.CONTENT appropriately. + self.perform_form_overloading() + + # Authenticate and check request is has the relevant permissions + self.check_permissions() + + # Get the appropriate handler method + if self.method.lower() in self.http_method_names: + handler = getattr(self, self.method.lower(), self.http_method_not_allowed) + else: + handler = self.http_method_not_allowed + + response_obj = handler(request, *args, **kwargs) + + # Allow return value to be either Response, or an object, or None + if isinstance(response_obj, Response): + response = response_obj + elif response_obj is not None: + response = Response(status.HTTP_200_OK, response_obj) + else: + response = Response(status.HTTP_204_NO_CONTENT) + + # Pre-serialize filtering (eg filter complex objects into natively serializable types) + response.cleaned_content = self.resource.object_to_serializable(response.raw_content) + + except ErrorResponse, exc: + response = exc.response + + # Always add these headers. + # + # TODO - this isn't actually the correct way to set the vary header, + # also it's currently sub-obtimal for HTTP caching - need to sort that out. + response.headers['Allow'] = ', '.join(self.allowed_methods) + response.headers['Vary'] = 'Authenticate, Accept' + + return self.render(response) + + +class ModelView(BaseView): + """A RESTful view that maps to a model in the database.""" + validators = (validators.ModelFormValidator,) + +class InstanceModelView(ReadModelMixin, UpdateModelMixin, DeleteModelMixin, ModelView): + """A view which provides default operations for read/update/delete against a model instance.""" + pass + +class ListModelResource(ListModelMixin, ModelView): + """A view which provides default operations for list, against a model in the database.""" + pass + +class ListOrCreateModelResource(ListModelMixin, CreateModelMixin, ModelView): + """A view which provides default operations for list and create, against a model in the database.""" + pass + -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) diff --git a/examples/modelresourceexample/views.py b/examples/modelresourceexample/views.py index 07f50b653..5495a2931 100644 --- a/examples/modelresourceexample/views.py +++ b/examples/modelresourceexample/views.py @@ -1,15 +1,15 @@ -from djangorestframework.modelresource import ModelResource, RootModelResource +from djangorestframework.modelresource import InstanceModelResource, ListOrCreateModelResource from modelresourceexample.models import MyModel FIELDS = ('foo', 'bar', 'baz', 'absolute_url') -class MyModelRootResource(RootModelResource): +class MyModelRootResource(ListOrCreateModelResource): """A create/list resource for MyModel. Available for both authenticated and anonymous access for the purposes of the sandbox.""" model = MyModel fields = FIELDS -class MyModelResource(ModelResource): +class MyModelResource(InstanceModelResource): """A read/update/delete resource for MyModel. Available for both authenticated and anonymous access for the purposes of the sandbox.""" model = MyModel diff --git a/examples/urls.py b/examples/urls.py index 7cb5e7ce4..cf4d4042c 100644 --- a/examples/urls.py +++ b/examples/urls.py @@ -2,11 +2,8 @@ from django.conf.urls.defaults import patterns, include, url from django.conf import settings from sandbox.views import Sandbox -urlpatterns = patterns('djangorestframework.views', - (r'robots.txt', 'deny_robots'), - +urlpatterns = patterns('', (r'^$', Sandbox.as_view()), - (r'^resource-example/', include('resourceexample.urls')), (r'^model-resource-example/', include('modelresourceexample.urls')), (r'^mixin/', include('mixin.urls')), @@ -14,14 +11,6 @@ urlpatterns = patterns('djangorestframework.views', (r'^pygments/', include('pygments_api.urls')), (r'^blog-post/', include('blogpost.urls')), - (r'^accounts/login/$', 'api_login'), - (r'^accounts/logout/$', 'api_logout'), + (r'^', include('djangorestframework.urls')), ) -# Only serve favicon in production because otherwise chrome users will pretty much -# permanantly have the django-rest-framework favicon whenever they navigate to -# 127.0.0.1:8000 or whatever, which gets annoying -if not settings.DEBUG: - urlpatterns += patterns('djangorestframework.views', - (r'favicon.ico', 'favicon'), - ) From 8f58ee489d34b200acfc2666816eb32e47c8cef5 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 10 May 2011 10:49:28 +0100 Subject: [PATCH 32/70] Getting the API into shape --- djangorestframework/authentication.py | 75 ++++++----- djangorestframework/mixins.py | 157 ++++++++++++------------ djangorestframework/parsers.py | 75 ++++++----- djangorestframework/permissions.py | 117 ++++++++++++------ djangorestframework/renderers.py | 46 +++---- djangorestframework/tests/content.py | 6 +- djangorestframework/tests/parsers.py | 11 +- djangorestframework/tests/throttling.py | 4 +- djangorestframework/utils/mediatypes.py | 42 +++++-- djangorestframework/views.py | 22 ++-- 10 files changed, 327 insertions(+), 228 deletions(-) diff --git a/djangorestframework/authentication.py b/djangorestframework/authentication.py index 9dd5c958d..dea19f916 100644 --- a/djangorestframework/authentication.py +++ b/djangorestframework/authentication.py @@ -1,43 +1,58 @@ -"""The :mod:`authentication` modules provides for pluggable authentication behaviour. - -Authentication behaviour is provided by adding the mixin class :class:`AuthenticatorMixin` to a :class:`.BaseView` or Django :class:`View` class. - -The set of authentication which are use is then specified by setting the :attr:`authentication` attribute on the class, and listing a set of authentication classes. """ +The ``authentication`` module provides a set of pluggable authentication classes. + +Authentication behavior is provided by adding the ``AuthMixin`` class to a ``View`` . + +The set of authentication methods which are used is then specified by setting +``authentication`` attribute on the ``View`` class, and listing a set of authentication classes. +""" + from django.contrib.auth import authenticate from django.middleware.csrf import CsrfViewMiddleware from djangorestframework.utils import as_tuple import base64 +__all__ = ( + 'BaseAuthenticaton', + 'BasicAuthenticaton', + 'UserLoggedInAuthenticaton' +) -class BaseAuthenticator(object): - """All authentication should extend BaseAuthenticator.""" + +class BaseAuthenticaton(object): + """ + All authentication classes should extend BaseAuthentication. + """ def __init__(self, view): - """Initialise the authentication with the mixin instance as state, - in case the authentication needs to access any metadata on the mixin object.""" + """ + Authentication classes are always passed the current view on creation. + """ self.view = view def authenticate(self, request): - """Authenticate the request and return the authentication context or None. + """ + Authenticate the request and return a ``User`` instance 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 View will use the allowed_methods attribute - for permissions if the authentication context is not None, and use anon_allowed_methods otherwise. - - The authentication context is available to the method calls eg View.get(request) - by accessing self.auth in order to allow them to apply any more fine grained permission - checking at the point the response is being generated. + This function must be overridden to be implemented. - This function must be overridden to be implemented.""" + (*) The authentication context _will_ typically be a ``User`` object, + but it need not be. It can be any user-like object so long as the + permissions classes on the view can handle the object and use + it to determine if the request has the required permissions or not. + + This can be an important distinction if you're implementing some token + based authentication mechanism, where the authentication context + may be more involved than simply mapping to a ``User``. + """ return None -class BasicAuthenticator(BaseAuthenticator): - """Use HTTP Basic authentication""" +class BasicAuthenticaton(BaseAuthenticaton): + """ + Use HTTP Basic authentication. + """ + def authenticate(self, request): from django.utils.encoding import smart_unicode, DjangoUnicodeDecodeError @@ -60,9 +75,13 @@ class BasicAuthenticator(BaseAuthenticator): return None -class UserLoggedInAuthenticator(BaseAuthenticator): - """Use Django's built-in request session for authentication.""" +class UserLoggedInAuthenticaton(BaseAuthenticaton): + """ + Use Django's session framework for authentication. + """ + def authenticate(self, request): + # TODO: Switch this back to request.POST, and let MultiPartParser deal with the consequences. if getattr(request, 'user', None) and request.user.is_active: # If this is a POST request we enforce CSRF validation. if request.method.upper() == 'POST': @@ -77,8 +96,4 @@ class UserLoggedInAuthenticator(BaseAuthenticator): return None -#class DigestAuthentication(BaseAuthentication): -# pass -# -#class OAuthAuthentication(BaseAuthentication): -# pass +# TODO: TokenAuthentication, DigestAuthentication, OAuthAuthentication diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 467ce0e0d..297d3f8d8 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -1,31 +1,38 @@ """""" -from djangorestframework.utils.mediatypes import MediaType -from djangorestframework.utils import as_tuple, MSIE_USER_AGENT_REGEX -from djangorestframework.response import ErrorResponse -from djangorestframework.parsers import FormParser, MultipartParser -from djangorestframework import status +from django.contrib.auth.models import AnonymousUser +from django.db.models.query import QuerySet +from django.db.models.fields.related import RelatedField from django.http import HttpResponse from django.http.multipartparser import LimitBytes # TODO: Use LimitedStream in compat -from StringIO import StringIO +from djangorestframework import status +from djangorestframework.parsers import FormParser, MultiPartParser +from djangorestframework.response import Response, ErrorResponse +from djangorestframework.utils import as_tuple, MSIE_USER_AGENT_REGEX +from djangorestframework.utils.mediatypes import is_form_media_type + from decimal import Decimal import re +from StringIO import StringIO -__all__ = ['RequestMixin', +__all__ = ('RequestMixin', 'ResponseMixin', 'AuthMixin', 'ReadModelMixin', 'CreateModelMixin', 'UpdateModelMixin', 'DeleteModelMixin', - 'ListModelMixin'] + 'ListModelMixin') + ########## Request Mixin ########## class RequestMixin(object): - """Mixin class to provide request parsing behaviour.""" + """ + Mixin class to provide request parsing behaviour. + """ USE_FORM_OVERLOADING = True METHOD_PARAM = "_method" @@ -53,41 +60,20 @@ class RequestMixin(object): def _get_content_type(self): """ - Returns a MediaType object, representing the request's content type header. + Returns the content type header. """ if not hasattr(self, '_content_type'): - content_type = self.request.META.get('HTTP_CONTENT_TYPE', self.request.META.get('CONTENT_TYPE', '')) - if content_type: - self._content_type = MediaType(content_type) - else: - self._content_type = None + self._content_type = self.request.META.get('HTTP_CONTENT_TYPE', self.request.META.get('CONTENT_TYPE', '')) return self._content_type def _set_content_type(self, content_type): """ - Set the content type. Should be a MediaType object. + Set the content type header. """ self._content_type = content_type - def _get_accept(self): - """ - Returns a list of MediaType objects, representing the request's accept header. - """ - if not hasattr(self, '_accept'): - accept = self.request.META.get('HTTP_ACCEPT', '*/*') - self._accept = [MediaType(elem) for elem in accept.split(',')] - return self._accept - - - def _set_accept(self): - """ - Set the acceptable media types. Should be a list of MediaType objects. - """ - self._accept = accept - - def _get_stream(self): """ Returns an object that may be used to stream the request content. @@ -115,7 +101,7 @@ class RequestMixin(object): # treated as a limited byte stream. # 2. It *can* be treated as a limited byte stream, in which case there's a # minor bug in the test client, and potentially some redundant - # code in MultipartParser. + # code in MultiPartParser. # # It's an issue because it affects if you can pass a request off to code that # does something like: @@ -166,12 +152,12 @@ class RequestMixin(object): If it is then alter self.method, self.content_type, self.CONTENT to reflect that rather than simply delegating them to the original request. """ - if not self.USE_FORM_OVERLOADING or self.method != 'POST' or not self.content_type.is_form(): + if not self.USE_FORM_OVERLOADING or self.method != 'POST' or not is_form_media_type(self.content_type): return # Temporarily switch to using the form parsers, then parse the content parsers = self.parsers - self.parsers = (FormParser, MultipartParser) + self.parsers = (FormParser, MultiPartParser) content = self.RAW_CONTENT self.parsers = parsers @@ -182,7 +168,7 @@ class RequestMixin(object): # Content overloading - rewind the stream and modify the content type if self.CONTENT_PARAM in content and self.CONTENTTYPE_PARAM in content: - self._content_type = MediaType(content[self.CONTENTTYPE_PARAM]) + self._content_type = content[self.CONTENTTYPE_PARAM] self._stream = StringIO(content[self.CONTENT_PARAM]) del(self._raw_content) @@ -191,26 +177,21 @@ class RequestMixin(object): """ Parse the request content. - May raise a 415 ErrorResponse (Unsupported Media Type), - or a 400 ErrorResponse (Bad Request). + May raise a 415 ErrorResponse (Unsupported Media Type), or a 400 ErrorResponse (Bad Request). """ if stream is None or content_type is None: return None parsers = as_tuple(self.parsers) - parser = None for parser_cls in parsers: - if parser_cls.handles(content_type): - parser = parser_cls(self) - break + parser = parser_cls(self) + if parser.can_handle_request(content_type): + return parser.parse(stream) - if parser is None: - raise ErrorResponse(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, - {'error': 'Unsupported media type in request \'%s\'.' % - content_type.media_type}) - - return parser.parse(stream) + raise ErrorResponse(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + {'error': 'Unsupported media type in request \'%s\'.' % + content_type}) def validate(self, content): @@ -250,7 +231,6 @@ class RequestMixin(object): method = property(_get_method, _set_method) content_type = property(_get_content_type, _set_content_type) - accept = property(_get_accept, _set_accept) stream = property(_get_stream, _set_stream) RAW_CONTENT = property(_get_raw_content) CONTENT = property(_get_content) @@ -259,11 +239,13 @@ class RequestMixin(object): ########## ResponseMixin ########## class ResponseMixin(object): - """Adds behaviour for pluggable Renderers to a :class:`.BaseView` or Django :class:`View`. class. + """ + Adds behavior for pluggable Renderers to a :class:`.BaseView` or Django :class:`View`. class. Default behaviour is to use standard HTTP Accept header content negotiation. Also supports overidding the content type by specifying an _accept= parameter in the URL. - Ignores Accept headers from Internet Explorer user agents and uses a sensible browser Accept header instead.""" + Ignores Accept headers from Internet Explorer user agents and uses a sensible browser Accept header instead. + """ ACCEPT_QUERY_PARAM = '_accept' # Allow override of Accept header in URL query params REWRITE_IE_ACCEPT_HEADER = True @@ -272,7 +254,9 @@ class ResponseMixin(object): def render(self, response): - """Takes a :class:`Response` object and returns a Django :class:`HttpResponse`.""" + """ + Takes a ``Response`` object and returns an ``HttpResponse``. + """ self.response = response try: @@ -374,7 +358,7 @@ class ResponseMixin(object): @property def default_renderer(self): - """Return the resource's most prefered renderer. + """Return the resource's most preferred renderer. (This renderer is used if the client does not send and Accept: header, or sends Accept: */*)""" return self.renderers[0] @@ -382,40 +366,49 @@ class ResponseMixin(object): ########## Auth Mixin ########## class AuthMixin(object): - """Mixin class to provide authentication and permission checking.""" + """ + Simple mixin class to provide authentication and permission checking, + by adding a set of authentication and permission classes on a ``View``. + + TODO: wrap this behavior around dispatch() + """ authentication = () permissions = () @property - def auth(self): - if not hasattr(self, '_auth'): - self._auth = self._authenticate() - return self._auth - + def user(self): + if not hasattr(self, '_user'): + self._user = self._authenticate() + return self._user + def _authenticate(self): + """ + Attempt to authenticate the request using each authentication class in turn. + Returns a ``User`` object, which may be ``AnonymousUser``. + """ for authentication_cls in self.authentication: authentication = authentication_cls(self) - auth = authentication.authenticate(self.request) - if auth: - return auth - return None - - def check_permissions(self): - if not self.permissions: - return + user = authentication.authenticate(self.request) + if user: + return user + return AnonymousUser() + def _check_permissions(self): + """ + Check user permissions and either raise an ``ErrorResponse`` or return. + """ + user = self.user for permission_cls in self.permissions: permission = permission_cls(self) - if not permission.has_permission(self.auth): - raise ErrorResponse(status.HTTP_403_FORBIDDEN, - {'detail': 'You do not have permission to access this resource. ' + - 'You may need to login or otherwise authenticate the request.'}) + permission.check_permission(user) ########## Model Mixins ########## class ReadModelMixin(object): - """Behaviour to read a model instance on GET requests""" + """ + Behavior to read a model instance on GET requests + """ def get(self, request, *args, **kwargs): model = self.resource.model try: @@ -432,7 +425,9 @@ class ReadModelMixin(object): class CreateModelMixin(object): - """Behaviour to create a model instance on POST requests""" + """ + Behavior to create a model instance on POST requests + """ def post(self, request, *args, **kwargs): model = self.resource.model # translated 'related_field' kwargs into 'related_field_id' @@ -454,7 +449,9 @@ class CreateModelMixin(object): class UpdateModelMixin(object): - """Behaviour to update a model instance on PUT requests""" + """ + Behavior to update a model instance on PUT requests + """ def put(self, request, *args, **kwargs): model = self.resource.model # TODO: update on the url of a non-existing resource url doesn't work correctly at the moment - will end up with a new url @@ -477,7 +474,9 @@ class UpdateModelMixin(object): class DeleteModelMixin(object): - """Behaviour to delete a model instance on DELETE requests""" + """ + Behavior to delete a model instance on DELETE requests + """ def delete(self, request, *args, **kwargs): model = self.resource.model try: @@ -495,11 +494,13 @@ class DeleteModelMixin(object): class ListModelMixin(object): - """Behaviour to list a set of model instances on GET requests""" + """ + Behavior to list a set of model instances on GET requests + """ queryset = None def get(self, request, *args, **kwargs): - queryset = self.queryset if self.queryset else self.model.objects.all() + queryset = self.queryset if self.queryset else self.resource.model.objects.all() return queryset.filter(**kwargs) diff --git a/djangorestframework/parsers.py b/djangorestframework/parsers.py index 6d6bd5cef..da700367d 100644 --- a/djangorestframework/parsers.py +++ b/djangorestframework/parsers.py @@ -1,5 +1,6 @@ -"""Django supports parsing the content of an HTTP request, but only for form POST requests. -That behaviour is sufficient for dealing with standard HTML forms, but it doesn't map well +""" +Django supports parsing the content of an HTTP request, but only for form POST requests. +That behavior is sufficient for dealing with standard HTML forms, but it doesn't map well to general HTTP requests. We need a method to be able to: @@ -8,54 +9,72 @@ We need a method to be able to: 2) Determine the parsed content on a request for media types other than application/x-www-form-urlencoded and multipart/form-data. (eg also handle multipart/json) """ -from django.http.multipartparser import MultiPartParser as DjangoMPParser -from django.utils import simplejson as json -from djangorestframework.response import ErrorResponse +from django.http.multipartparser import MultiPartParser as DjangoMultiPartParser +from django.utils import simplejson as json from djangorestframework import status -from djangorestframework.utils import as_tuple -from djangorestframework.utils.mediatypes import MediaType from djangorestframework.compat import parse_qs +from djangorestframework.response import ErrorResponse +from djangorestframework.utils import as_tuple +from djangorestframework.utils.mediatypes import media_type_matches + +__all__ = ( + 'BaseParser', + 'JSONParser', + 'PlainTextParser', + 'FormParser', + 'MultiPartParser' +) class BaseParser(object): - """All parsers should extend BaseParser, specifying a media_type attribute, - and overriding the parse() method.""" + """ + All parsers should extend BaseParser, specifying a media_type attribute, + and overriding the parse() method. + """ media_type = None def __init__(self, view): """ - Initialise the parser with the View instance as state, - in case the parser needs to access any metadata on the View object. - + Initialize the parser with the ``View`` instance as state, + in case the parser needs to access any metadata on the ``View`` object. """ self.view = view - @classmethod - def handles(self, media_type): + def can_handle_request(self, media_type): """ - Returns `True` if this parser is able to deal with the given MediaType. + Returns `True` if this parser is able to deal with the given media type. + + The default implementation for this function is to check the ``media_type`` + argument against the ``media_type`` attribute set on the class to see if + they match. + + This may be overridden to provide for other behavior, but typically you'll + instead want to just set the ``media_type`` attribute on the class. """ - return media_type.match(self.media_type) + return media_type_matches(media_type, self.media_type) def parse(self, stream): - """Given a stream to read from, return the deserialized output. - The return value may be of any type, but for many parsers it might typically be a dict-like object.""" + """ + Given a stream to read from, return the deserialized output. + The return value may be of any type, but for many parsers it might typically be a dict-like object. + """ raise NotImplementedError("BaseParser.parse() Must be overridden to be implemented.") class JSONParser(BaseParser): - media_type = MediaType('application/json') + media_type = 'application/json' def parse(self, stream): try: return json.load(stream) except ValueError, exc: - raise ErrorResponse(status.HTTP_400_BAD_REQUEST, {'detail': 'JSON parse error - %s' % str(exc)}) + raise ErrorResponse(status.HTTP_400_BAD_REQUEST, + {'detail': 'JSON parse error - %s' % unicode(exc)}) class DataFlatener(object): - """Utility object for flatening dictionaries of lists. Useful for "urlencoded" decoded data.""" + """Utility object for flattening dictionaries of lists. Useful for "urlencoded" decoded data.""" def flatten_data(self, data): """Given a data dictionary {: }, returns a flattened dictionary @@ -83,9 +102,9 @@ class PlainTextParser(BaseParser): """ Plain text parser. - Simply returns the content of the stream + Simply returns the content of the stream. """ - media_type = MediaType('text/plain') + media_type = 'text/plain' def parse(self, stream): return stream.read() @@ -98,7 +117,7 @@ class FormParser(BaseParser, DataFlatener): In order to handle select multiple (and having possibly more than a single value for each parameter), you can customize the output by subclassing the method 'is_a_list'.""" - media_type = MediaType('application/x-www-form-urlencoded') + media_type = 'application/x-www-form-urlencoded' """The value of the parameter when the select multiple is empty. Browsers are usually stripping the select multiple that have no option selected from the parameters sent. @@ -138,14 +157,14 @@ class MultipartData(dict): dict.__init__(self, data) self.FILES = files -class MultipartParser(BaseParser, DataFlatener): - media_type = MediaType('multipart/form-data') +class MultiPartParser(BaseParser, DataFlatener): + media_type = 'multipart/form-data' RESERVED_FORM_PARAMS = ('csrfmiddlewaretoken',) def parse(self, stream): upload_handlers = self.view.request._get_upload_handlers() - django_mpp = DjangoMPParser(self.view.request.META, stream, upload_handlers) - data, files = django_mpp.parse() + django_parser = DjangoMultiPartParser(self.view.request.META, stream, upload_handlers) + data, files = django_parser.parse() # Flatening data, files and combining them data = self.flatten_data(dict(data.iterlists())) diff --git a/djangorestframework/permissions.py b/djangorestframework/permissions.py index d98651e0c..1b1515581 100644 --- a/djangorestframework/permissions.py +++ b/djangorestframework/permissions.py @@ -1,66 +1,103 @@ from django.core.cache import cache from djangorestframework import status +from djangorestframework.response import ErrorResponse import time +__all__ = ( + 'BasePermission', + 'FullAnonAccess', + 'IsAuthenticated', + 'IsAdminUser', + 'IsUserOrIsAnonReadOnly', + 'PerUserThrottling' +) + + +_403_FORBIDDEN_RESPONSE = ErrorResponse( + status.HTTP_403_FORBIDDEN, + {'detail': 'You do not have permission to access this resource. ' + + 'You may need to login or otherwise authenticate the request.'}) + +_503_THROTTLED_RESPONSE = ErrorResponse( + status.HTTP_503_SERVICE_UNAVAILABLE, + {'detail': 'request was throttled'}) + + class BasePermission(object): - """A base class from which all permission classes should inherit.""" + """ + A base class from which all permission classes should inherit. + """ def __init__(self, view): + """ + Permission classes are always passed the current view on creation. + """ self.view = view - def has_permission(self, auth): - return True + def check_permission(self, auth): + """ + Should simply return, or raise an ErrorResponse. + """ + pass class FullAnonAccess(BasePermission): - """""" - def has_permission(self, auth): - return True + """ + Allows full access. + """ + + def check_permission(self, user): + pass + class IsAuthenticated(BasePermission): - """""" - def has_permission(self, auth): - return auth is not None and auth.is_authenticated() + """ + Allows access only to authenticated users. + """ -#class IsUser(BasePermission): -# """The request has authenticated as a user.""" -# def has_permission(self, auth): -# pass -# -#class IsAdminUser(): -# """The request has authenticated as an admin user.""" -# def has_permission(self, auth): -# pass -# -#class IsUserOrIsAnonReadOnly(BasePermission): -# """The request has authenticated as a user, or is a read-only request.""" -# def has_permission(self, auth): -# pass -# -#class OAuthTokenInScope(BasePermission): -# def has_permission(self, auth): -# pass -# -#class UserHasModelPermissions(BasePermission): -# def has_permission(self, auth): -# pass - + def check_permission(self, user): + if not user.is_authenticated(): + raise _403_FORBIDDEN_RESPONSE -class Throttling(BasePermission): - """Rate throttling of requests on a per-user basis. +class IsAdminUser(): + """ + Allows access only to admin users. + """ - The rate is set by a 'throttle' attribute on the view class. + def check_permission(self, user): + if not user.is_admin(): + raise _403_FORBIDDEN_RESPONSE + + +class IsUserOrIsAnonReadOnly(BasePermission): + """ + The request is authenticated as a user, or is a read-only request. + """ + + def check_permission(self, user): + if (not user.is_authenticated() and + self.view.method != 'GET' and + self.view.method != 'HEAD'): + raise _403_FORBIDDEN_RESPONSE + + +class PerUserThrottling(BasePermission): + """ + Rate throttling of requests on a per-user basis. + + The rate is set by a 'throttle' attribute on the ``View`` class. The attribute is a two tuple of the form (number of requests, duration in seconds). - The user's id will be used as a unique identifier if the user is authenticated. + The user id will be used as a unique identifier if the user is authenticated. For anonymous requests, the IP address of the client will be used. Previous request information used for throttling is stored in the cache. """ - def has_permission(self, auth): + + def check_permission(self, user): (num_requests, duration) = getattr(self.view, 'throttle', (0, 0)) - if auth.is_authenticated(): + if user.is_authenticated(): ident = str(auth) else: ident = self.view.request.META.get('REMOTE_ADDR', None) @@ -74,7 +111,7 @@ class Throttling(BasePermission): history.pop() if len(history) >= num_requests: - raise ErrorResponse(status.HTTP_503_SERVICE_UNAVAILABLE, {'detail': 'request was throttled'}) + raise _503_THROTTLED_RESPONSE history.insert(0, now) - cache.set(key, history, duration) + cache.set(key, history, duration) diff --git a/djangorestframework/renderers.py b/djangorestframework/renderers.py index 9e4e20533..78dc26b5e 100644 --- a/djangorestframework/renderers.py +++ b/djangorestframework/renderers.py @@ -29,8 +29,8 @@ class BaseRenderer(object): override the render() function.""" media_type = None - def __init__(self, resource): - self.resource = resource + def __init__(self, view): + self.view = view def render(self, output=None, verbose=False): """By default render simply returns the ouput as-is. @@ -42,8 +42,11 @@ class BaseRenderer(object): class TemplateRenderer(BaseRenderer): - """Provided for convienience. - Render the output by simply rendering it with the given template.""" + """A Base class provided for convenience. + + Render the output simply by using the given template. + To create a template renderer, subclass this, and set + the ``media_type`` and ``template`` attributes""" media_type = None template = None @@ -139,7 +142,7 @@ class DocumentingTemplateRenderer(BaseRenderer): widget=forms.Textarea) # If either of these reserved parameters are turned off then content tunneling is not possible - if self.resource.CONTENTTYPE_PARAM is None or self.resource.CONTENT_PARAM is None: + if self.view.CONTENTTYPE_PARAM is None or self.view.CONTENT_PARAM is None: return None # Okey doke, let's do it @@ -147,18 +150,18 @@ class DocumentingTemplateRenderer(BaseRenderer): def render(self, output=None): - content = self._get_content(self.resource, self.resource.request, output) - form_instance = self._get_form_instance(self.resource) + content = self._get_content(self.view, self.view.request, output) + form_instance = self._get_form_instance(self.view) if url_resolves(settings.LOGIN_URL) and url_resolves(settings.LOGOUT_URL): - login_url = "%s?next=%s" % (settings.LOGIN_URL, quote_plus(self.resource.request.path)) - logout_url = "%s?next=%s" % (settings.LOGOUT_URL, quote_plus(self.resource.request.path)) + login_url = "%s?next=%s" % (settings.LOGIN_URL, quote_plus(self.view.request.path)) + logout_url = "%s?next=%s" % (settings.LOGOUT_URL, quote_plus(self.view.request.path)) else: login_url = None logout_url = None - name = get_name(self.resource) - description = get_description(self.resource) + name = get_name(self.view) + description = get_description(self.view) markeddown = None if apply_markdown: @@ -167,14 +170,14 @@ class DocumentingTemplateRenderer(BaseRenderer): except AttributeError: # TODO: possibly split the get_description / get_name into a mixin class markeddown = None - breadcrumb_list = get_breadcrumbs(self.resource.request.path) + breadcrumb_list = get_breadcrumbs(self.view.request.path) template = loader.get_template(self.template) - context = RequestContext(self.resource.request, { + context = RequestContext(self.view.request, { 'content': content, - 'resource': self.resource, - 'request': self.resource.request, - 'response': self.resource.response, + 'resource': self.view, + 'request': self.view.request, + 'response': self.view.response, 'description': description, 'name': name, 'markeddown': markeddown, @@ -233,11 +236,12 @@ class DocumentingPlainTextRenderer(DocumentingTemplateRenderer): Useful for browsing an API with command line tools.""" media_type = 'text/plain' template = 'renderer.txt' - + + DEFAULT_RENDERERS = ( JSONRenderer, - DocumentingHTMLRenderer, - DocumentingXHTMLRenderer, - DocumentingPlainTextRenderer, - XMLRenderer ) + DocumentingHTMLRenderer, + DocumentingXHTMLRenderer, + DocumentingPlainTextRenderer, + XMLRenderer ) diff --git a/djangorestframework/tests/content.py b/djangorestframework/tests/content.py index 6695bf688..e566ea009 100644 --- a/djangorestframework/tests/content.py +++ b/djangorestframework/tests/content.py @@ -4,7 +4,7 @@ Tests for content parsing, and form-overloaded content parsing. from django.test import TestCase from djangorestframework.compat import RequestFactory from djangorestframework.mixins import RequestMixin -from djangorestframework.parsers import FormParser, MultipartParser, PlainTextParser +from djangorestframework.parsers import FormParser, MultiPartParser, PlainTextParser class TestContentParsing(TestCase): @@ -19,7 +19,7 @@ class TestContentParsing(TestCase): def ensure_determines_form_content_POST(self, view): """Ensure view.RAW_CONTENT returns content for POST request with form content.""" form_data = {'qwerty': 'uiop'} - view.parsers = (FormParser, MultipartParser) + view.parsers = (FormParser, MultiPartParser) view.request = self.req.post('/', data=form_data) self.assertEqual(view.RAW_CONTENT, form_data) @@ -34,7 +34,7 @@ class TestContentParsing(TestCase): def ensure_determines_form_content_PUT(self, view): """Ensure view.RAW_CONTENT returns content for PUT request with form content.""" form_data = {'qwerty': 'uiop'} - view.parsers = (FormParser, MultipartParser) + view.parsers = (FormParser, MultiPartParser) view.request = self.req.put('/', data=form_data) self.assertEqual(view.RAW_CONTENT, form_data) diff --git a/djangorestframework/tests/parsers.py b/djangorestframework/tests/parsers.py index 049ac741e..88aad8805 100644 --- a/djangorestframework/tests/parsers.py +++ b/djangorestframework/tests/parsers.py @@ -39,7 +39,7 @@ This new parser only flattens the lists of parameters that contain a single valu >>> MyFormParser(some_view).parse(StringIO(inpt)) == {'key1': 'bla1', 'key2': ['blo1', 'blo2']} True -.. note:: The same functionality is available for :class:`parsers.MultipartParser`. +.. note:: The same functionality is available for :class:`parsers.MultiPartParser`. Submitting an empty list -------------------------- @@ -80,9 +80,8 @@ import httplib, mimetypes from tempfile import TemporaryFile from django.test import TestCase from djangorestframework.compat import RequestFactory -from djangorestframework.parsers import MultipartParser +from djangorestframework.parsers import MultiPartParser from djangorestframework.views import BaseView -from djangorestframework.utils.mediatypes import MediaType from StringIO import StringIO def encode_multipart_formdata(fields, files): @@ -113,18 +112,18 @@ def encode_multipart_formdata(fields, files): def get_content_type(filename): return mimetypes.guess_type(filename)[0] or 'application/octet-stream' -class TestMultipartParser(TestCase): +class TestMultiPartParser(TestCase): def setUp(self): self.req = RequestFactory() self.content_type, self.body = encode_multipart_formdata([('key1', 'val1'), ('key1', 'val2')], [('file1', 'pic.jpg', 'blablabla'), ('file1', 't.txt', 'blobloblo')]) def test_multipartparser(self): - """Ensure that MultipartParser can parse multipart/form-data that contains a mix of several files and parameters.""" + """Ensure that MultiPartParser can parse multipart/form-data that contains a mix of several files and parameters.""" post_req = RequestFactory().post('/', self.body, content_type=self.content_type) view = BaseView() view.request = post_req - parsed = MultipartParser(view).parse(StringIO(self.body)) + parsed = MultiPartParser(view).parse(StringIO(self.body)) self.assertEqual(parsed['key1'], 'val1') self.assertEqual(parsed.FILES['file1'].read(), 'blablabla') diff --git a/djangorestframework/tests/throttling.py b/djangorestframework/tests/throttling.py index 94d014288..e7a054cd0 100644 --- a/djangorestframework/tests/throttling.py +++ b/djangorestframework/tests/throttling.py @@ -4,11 +4,11 @@ from django.utils import simplejson as json from djangorestframework.compat import RequestFactory from djangorestframework.views import BaseView -from djangorestframework.permissions import Throttling +from djangorestframework.permissions import PerUserThrottling class MockView(BaseView): - permissions = ( Throttling, ) + permissions = ( PerUserThrottling, ) throttle = (3, 1) # 3 requests per second def get(self, request): diff --git a/djangorestframework/utils/mediatypes.py b/djangorestframework/utils/mediatypes.py index 92d9264c3..3bf914e4f 100644 --- a/djangorestframework/utils/mediatypes.py +++ b/djangorestframework/utils/mediatypes.py @@ -7,11 +7,39 @@ See http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7 from django.http.multipartparser import parse_header -class MediaType(object): +def media_type_matches(lhs, rhs): + """ + Returns ``True`` if the media type in the first argument <= the + media type in the second argument. The media types are strings + as described by the HTTP spec. + + Valid media type strings include: + + 'application/json indent=4' + 'application/json' + 'text/*' + '*/*' + """ + lhs = _MediaType(lhs) + rhs = _MediaType(rhs) + return lhs.match(rhs) + + +def is_form_media_type(media_type): + """ + Return True if the media type is a valid form media type as defined by the HTML4 spec. + (NB. HTML5 also adds text/plain to the list of valid form media types, but we don't support this here) + """ + media_type = _MediaType(media_type) + return media_type.full_type == 'application/x-www-form-urlencoded' or \ + media_type.full_type == 'multipart/form-data' + + +class _MediaType(object): def __init__(self, media_type_str): self.orig = media_type_str - self.media_type, self.params = parse_header(media_type_str) - self.main_type, sep, self.sub_type = self.media_type.partition('/') + self.full_type, self.params = parse_header(media_type_str) + self.main_type, sep, self.sub_type = self.full_type.partition('/') def match(self, other): """Return true if this MediaType satisfies the constraint of the given MediaType.""" @@ -55,14 +83,6 @@ class MediaType(object): # NB. quality values should only have up to 3 decimal points # http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.9 return self.quality * 10000 + self.precedence - - def is_form(self): - """ - Return True if the MediaType is a valid form media type as defined by the HTML4 spec. - (NB. HTML5 also adds text/plain to the list of valid form media types, but we don't support this here) - """ - return self.media_type == 'application/x-www-form-urlencoded' or \ - self.media_type == 'multipart/form-data' def as_tuple(self): return (self.main_type, self.sub_type, self.params) diff --git a/djangorestframework/views.py b/djangorestframework/views.py index dd30a0924..02251885d 100644 --- a/djangorestframework/views.py +++ b/djangorestframework/views.py @@ -7,11 +7,11 @@ from djangorestframework.mixins import * from djangorestframework import resource, renderers, parsers, authentication, permissions, validators, status -__all__ = ['BaseView', +__all__ = ('BaseView', 'ModelView', 'InstanceModelView', 'ListOrModelView', - 'ListOrCreateModelView'] + 'ListOrCreateModelView') @@ -32,14 +32,14 @@ class BaseView(RequestMixin, ResponseMixin, AuthMixin, View): # List of parsers the resource can parse the request with. parsers = ( parsers.JSONParser, parsers.FormParser, - parsers.MultipartParser ) + parsers.MultiPartParser ) # List of validators to validate, cleanup and normalize the request content validators = ( validators.FormValidator, ) # List of all authenticating methods to attempt. - authentication = ( authentication.UserLoggedInAuthenticator, - authentication.BasicAuthenticator ) + authentication = ( authentication.UserLoggedInAuthenticaton, + authentication.BasicAuthenticaton ) # List of all permissions that must be checked. permissions = ( permissions.FullAnonAccess, ) @@ -92,7 +92,7 @@ class BaseView(RequestMixin, ResponseMixin, AuthMixin, View): self.perform_form_overloading() # Authenticate and check request is has the relevant permissions - self.check_permissions() + self._check_permissions() # Get the appropriate handler method if self.method.lower() in self.http_method_names: @@ -112,9 +112,12 @@ class BaseView(RequestMixin, ResponseMixin, AuthMixin, View): # Pre-serialize filtering (eg filter complex objects into natively serializable types) response.cleaned_content = self.resource.object_to_serializable(response.raw_content) - + except ErrorResponse, exc: response = exc.response + except: + import traceback + traceback.print_exc() # Always add these headers. # @@ -124,6 +127,7 @@ class BaseView(RequestMixin, ResponseMixin, AuthMixin, View): response.headers['Vary'] = 'Authenticate, Accept' return self.render(response) + class ModelView(BaseView): @@ -134,11 +138,11 @@ class InstanceModelView(ReadModelMixin, UpdateModelMixin, DeleteModelMixin, Mode """A view which provides default operations for read/update/delete against a model instance.""" pass -class ListModelResource(ListModelMixin, ModelView): +class ListModelView(ListModelMixin, ModelView): """A view which provides default operations for list, against a model in the database.""" pass -class ListOrCreateModelResource(ListModelMixin, CreateModelMixin, ModelView): +class ListOrCreateModelView(ListModelMixin, CreateModelMixin, ModelView): """A view which provides default operations for list and create, against a model in the database.""" pass From 527e4ffdf7f7798dc17757a26d8fd6b155a49bf9 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 10 May 2011 12:21:48 +0100 Subject: [PATCH 33/70] renderer API work --- djangorestframework/mixins.py | 20 ++-- djangorestframework/renderers.py | 129 +++++++++++++++--------- djangorestframework/tests/renderers.py | 45 +++++++-- djangorestframework/utils/__init__.py | 14 ++- djangorestframework/utils/mediatypes.py | 29 +++++- djangorestframework/views.py | 104 ++++++++++--------- 6 files changed, 222 insertions(+), 119 deletions(-) diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 297d3f8d8..0a45ef08c 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -17,14 +17,16 @@ import re from StringIO import StringIO -__all__ = ('RequestMixin', - 'ResponseMixin', - 'AuthMixin', - 'ReadModelMixin', - 'CreateModelMixin', - 'UpdateModelMixin', - 'DeleteModelMixin', - 'ListModelMixin') +__all__ = ( + 'RequestMixin', + 'ResponseMixin', + 'AuthMixin', + 'ReadModelMixin', + 'CreateModelMixin', + 'UpdateModelMixin', + 'DeleteModelMixin', + 'ListModelMixin' +) ########## Request Mixin ########## @@ -267,7 +269,7 @@ class ResponseMixin(object): # Serialize the response content if response.has_content_body: - content = renderer(self).render(output=response.cleaned_content) + content = renderer(self).render(response.cleaned_content, renderer.media_type) else: content = renderer(self).render() diff --git a/djangorestframework/renderers.py b/djangorestframework/renderers.py index 78dc26b5e..6c3d23e29 100644 --- a/djangorestframework/renderers.py +++ b/djangorestframework/renderers.py @@ -1,4 +1,5 @@ -"""Renderers are used to serialize a View's output into specific media types. +""" +Renderers are used to serialize a View's output into specific media types. django-rest-framework also provides HTML and PlainText renderers that help self-document the API, by serializing the output along with documentation regarding the Resource, output status and headers, and providing forms and links depending on the allowed methods, renderers and parsers on the Resource. @@ -7,64 +8,78 @@ from django import forms from django.conf import settings from django.template import RequestContext, loader from django.utils import simplejson as json -from django import forms -from djangorestframework.utils import dict2xml, url_resolves +from djangorestframework import status from djangorestframework.compat import apply_markdown +from djangorestframework.utils import dict2xml, url_resolves from djangorestframework.utils.breadcrumbs import get_breadcrumbs from djangorestframework.utils.description import get_name, get_description -from djangorestframework import status +from djangorestframework.utils.mediatypes import get_media_type_params, add_media_type_param -from urllib import quote_plus -import string -import re from decimal import Decimal +import re +import string +from urllib import quote_plus + +__all__ = ( + 'BaseRenderer', + 'JSONRenderer', + 'DocumentingHTMLRenderer', + 'DocumentingXHTMLRenderer', + 'DocumentingPlainTextRenderer', + 'XMLRenderer' +) -# TODO: Rename verbose to something more appropriate -# TODO: Maybe None could be handled more cleanly. It'd be nice if it was handled by default, -# and only have an renderer output anything if it explicitly provides support for that. class BaseRenderer(object): - """All renderers must extend this class, set the media_type attribute, and - override the render() function.""" + """ + All renderers must extend this class, set the media_type attribute, and + override the render() function. + """ media_type = None def __init__(self, view): self.view = view - def render(self, output=None, verbose=False): - """By default render simply returns the ouput as-is. - Override this method to provide for other behaviour.""" - if output is None: + def render(self, obj=None, media_type=None): + """ + By default render simply returns the ouput as-is. + Override this method to provide for other behavior. + """ + if obj is None: return '' - return output + return obj class TemplateRenderer(BaseRenderer): - """A Base class provided for convenience. + """ + A Base class provided for convenience. - Render the output simply by using the given template. + Render the object simply by using the given template. To create a template renderer, subclass this, and set - the ``media_type`` and ``template`` attributes""" + the ``media_type`` and ``template`` attributes + """ media_type = None template = None - def render(self, output=None, verbose=False): - if output is None: + def render(self, obj=None, media_type=None): + if obj is None: return '' - context = RequestContext(self.request, output) + context = RequestContext(self.request, obj) return self.template.render(context) class DocumentingTemplateRenderer(BaseRenderer): - """Base class for renderers used to self-document the API. - Implementing classes should extend this class and set the template attribute.""" + """ + Base class for renderers used to self-document the API. + Implementing classes should extend this class and set the template attribute. + """ template = None - def _get_content(self, resource, request, output): - """Get the content as if it had been renderted by a non-documenting renderer. + def _get_content(self, resource, request, obj, media_type): + """Get the content as if it had been rendered by a non-documenting renderer. (Typically this will be the content as it would have been if the Resource had been requested with an 'Accept: */*' header, although with verbose style formatting if appropriate.)""" @@ -73,8 +88,9 @@ class DocumentingTemplateRenderer(BaseRenderer): renderers = [renderer for renderer in resource.renderers if not isinstance(renderer, DocumentingTemplateRenderer)] if not renderers: return '[No renderers were found]' - - content = renderers[0](resource).render(output, verbose=True) + + media_type = add_media_type_param(media_type, 'indent', '4') + content = renderers[0](resource).render(obj, media_type) if not all(char in string.printable for char in content): return '[%d bytes of binary content]' @@ -149,8 +165,8 @@ class DocumentingTemplateRenderer(BaseRenderer): return GenericContentForm(resource) - def render(self, output=None): - content = self._get_content(self.view, self.view.request, output) + def render(self, obj=None, media_type=None): + content = self._get_content(self.view, self.view.request, obj, media_type) form_instance = self._get_form_instance(self.view) if url_resolves(settings.LOGIN_URL) and url_resolves(settings.LOGOUT_URL): @@ -194,46 +210,63 @@ class DocumentingTemplateRenderer(BaseRenderer): class JSONRenderer(BaseRenderer): - """Renderer which serializes to JSON""" + """ + Renderer which serializes to JSON + """ media_type = 'application/json' - def render(self, output=None, verbose=False): - if output is None: + def render(self, obj=None, media_type=None): + if obj is None: return '' - if verbose: - return json.dumps(output, indent=4, sort_keys=True) - return json.dumps(output) + + indent = get_media_type_params(media_type).get('indent', None) + if indent is not None: + try: + indent = int(indent) + except ValueError: + indent = None + + sort_keys = indent and True or False + return json.dumps(obj, indent=indent, sort_keys=sort_keys) class XMLRenderer(BaseRenderer): - """Renderer which serializes to XML.""" + """ + Renderer which serializes to XML. + """ media_type = 'application/xml' - def render(self, output=None, verbose=False): - if output is None: + def render(self, obj=None, media_type=None): + if obj is None: return '' - return dict2xml(output) + return dict2xml(obj) class DocumentingHTMLRenderer(DocumentingTemplateRenderer): - """Renderer which provides a browsable HTML interface for an API. - See the examples listed in the django-rest-framework documentation to see this in actions.""" + """ + Renderer which provides a browsable HTML interface for an API. + See the examples at http://api.django-rest-framework.org to see this in action. + """ media_type = 'text/html' template = 'renderer.html' class DocumentingXHTMLRenderer(DocumentingTemplateRenderer): - """Identical to DocumentingHTMLRenderer, except with an xhtml media type. + """ + Identical to DocumentingHTMLRenderer, except with an xhtml media type. We need this to be listed in preference to xml in order to return HTML to WebKit based browsers, - given their Accept headers.""" + given their Accept headers. + """ media_type = 'application/xhtml+xml' template = 'renderer.html' class DocumentingPlainTextRenderer(DocumentingTemplateRenderer): - """Renderer that serializes the output with the default renderer, but also provides plain-text - doumentation of the returned status and headers, and of the resource's name and description. - Useful for browsing an API with command line tools.""" + """ + Renderer that serializes the object with the default renderer, but also provides plain-text + documentation of the returned status and headers, and of the resource's name and description. + Useful for browsing an API with command line tools. + """ media_type = 'text/plain' template = 'renderer.txt' diff --git a/djangorestframework/tests/renderers.py b/djangorestframework/tests/renderers.py index df0d9c8d4..fcc405a19 100644 --- a/djangorestframework/tests/renderers.py +++ b/djangorestframework/tests/renderers.py @@ -2,9 +2,10 @@ from django.conf.urls.defaults import patterns, url from django import http from django.test import TestCase from djangorestframework.compat import View -from djangorestframework.renderers import BaseRenderer +from djangorestframework.renderers import BaseRenderer, JSONRenderer from djangorestframework.mixins import ResponseMixin from djangorestframework.response import Response +from djangorestframework.utils.mediatypes import add_media_type_param DUMMYSTATUS = 200 DUMMYCONTENT = 'dummycontent' @@ -20,14 +21,14 @@ class MockView(ResponseMixin, View): class RendererA(BaseRenderer): media_type = 'mock/renderera' - def render(self, output, verbose=False): - return RENDERER_A_SERIALIZER(output) + def render(self, obj=None, content_type=None): + return RENDERER_A_SERIALIZER(obj) class RendererB(BaseRenderer): media_type = 'mock/rendererb' - def render(self, output, verbose=False): - return RENDERER_B_SERIALIZER(output) + def render(self, obj=None, content_type=None): + return RENDERER_B_SERIALIZER(obj) urlpatterns = patterns('', @@ -36,7 +37,9 @@ urlpatterns = patterns('', class RendererIntegrationTests(TestCase): - """End-to-end testing of renderers using an RendererMixin on a generic view.""" + """ + End-to-end testing of renderers using an RendererMixin on a generic view. + """ urls = 'djangorestframework.tests.renderers' @@ -73,4 +76,32 @@ class RendererIntegrationTests(TestCase): 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) \ No newline at end of file + self.assertEquals(resp.status_code, 406) + + + +_flat_repr = '{"foo": ["bar", "baz"]}' + +_indented_repr = """{ + "foo": [ + "bar", + "baz" + ] +}""" + + +class JSONRendererTests(TestCase): + """ + Tests specific to the JSON Renderer + """ + def test_without_content_type_args(self): + obj = {'foo':['bar','baz']} + renderer = JSONRenderer(None) + content = renderer.render(obj, 'application/json') + self.assertEquals(content, _flat_repr) + + def test_with_content_type_args(self): + obj = {'foo':['bar','baz']} + renderer = JSONRenderer(None) + content = renderer.render(obj, 'application/json; indent=2') + self.assertEquals(content, _indented_repr) diff --git a/djangorestframework/utils/__init__.py b/djangorestframework/utils/__init__.py index 9dc769be2..67870001f 100644 --- a/djangorestframework/utils/__init__.py +++ b/djangorestframework/utils/__init__.py @@ -16,7 +16,15 @@ import xml.etree.ElementTree as ET MSIE_USER_AGENT_REGEX = re.compile(r'^Mozilla/[0-9]+\.[0-9]+ \([^)]*; MSIE [0-9]+\.[0-9]+[a-z]?;[^)]*\)(?!.* Opera )') def as_tuple(obj): - """Given obj return a tuple""" + """ + Given an object which may be a list/tuple, another object, or None, + return that object in list form. + + IE: + If the object is already a list/tuple just return it. + If the object is not None, return it in a list with a single element. + If the object is None return an empty list. + """ if obj is None: return () elif isinstance(obj, list): @@ -27,7 +35,9 @@ def as_tuple(obj): def url_resolves(url): - """Return True if the given URL is mapped to a view in the urlconf, False otherwise.""" + """ + Return True if the given URL is mapped to a view in the urlconf, False otherwise. + """ try: resolve(url) except: diff --git a/djangorestframework/utils/mediatypes.py b/djangorestframework/utils/mediatypes.py index 3bf914e4f..62a5e6f36 100644 --- a/djangorestframework/utils/mediatypes.py +++ b/djangorestframework/utils/mediatypes.py @@ -15,7 +15,7 @@ def media_type_matches(lhs, rhs): Valid media type strings include: - 'application/json indent=4' + 'application/json; indent=4' 'application/json' 'text/*' '*/*' @@ -33,10 +33,28 @@ def is_form_media_type(media_type): media_type = _MediaType(media_type) return media_type.full_type == 'application/x-www-form-urlencoded' or \ media_type.full_type == 'multipart/form-data' - - + + +def add_media_type_param(media_type, key, val): + """ + Add a key, value parameter to a media type string, and return the new media type string. + """ + media_type = _MediaType(media_type) + media_type.params[key] = val + return str(media_type) + +def get_media_type_params(media_type): + """ + Return a dictionary of the parameters on the given media type. + """ + return _MediaType(media_type).params + + + class _MediaType(object): def __init__(self, media_type_str): + if media_type_str is None: + media_type_str = '' self.orig = media_type_str self.full_type, self.params = parse_header(media_type_str) self.main_type, sep, self.sub_type = self.full_type.partition('/') @@ -94,5 +112,8 @@ class _MediaType(object): return unicode(self).encode('utf-8') def __unicode__(self): - return self.orig + ret = "%s/%s" % (self.main_type, self.sub_type) + for key, val in self.params.items(): + ret += "; %s=%s" % (key, val) + return ret diff --git a/djangorestframework/views.py b/djangorestframework/views.py index 02251885d..3ce4e1d69 100644 --- a/djangorestframework/views.py +++ b/djangorestframework/views.py @@ -7,11 +7,13 @@ from djangorestframework.mixins import * from djangorestframework import resource, renderers, parsers, authentication, permissions, validators, status -__all__ = ('BaseView', - 'ModelView', - 'InstanceModelView', - 'ListOrModelView', - 'ListOrCreateModelView') +__all__ = ( + 'BaseView', + 'ModelView', + 'InstanceModelView', + 'ListOrModelView', + 'ListOrCreateModelView' +) @@ -78,55 +80,59 @@ class BaseView(RequestMixin, ResponseMixin, AuthMixin, View): # all other authentication is CSRF exempt. @csrf_exempt def dispatch(self, request, *args, **kwargs): - self.request = request - self.args = args - self.kwargs = kwargs - - # 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) - try: - # If using a form POST with '_method'/'_content'/'_content_type' overrides, then alter - # self.method, self.content_type, self.RAW_CONTENT & self.CONTENT appropriately. - self.perform_form_overloading() - - # Authenticate and check request is has the relevant permissions - self._check_permissions() - - # Get the appropriate handler method - if self.method.lower() in self.http_method_names: - handler = getattr(self, self.method.lower(), self.http_method_not_allowed) - else: - handler = self.http_method_not_allowed - - response_obj = handler(request, *args, **kwargs) - - # Allow return value to be either Response, or an object, or None - if isinstance(response_obj, Response): - response = response_obj - elif response_obj is not None: - response = Response(status.HTTP_200_OK, response_obj) - else: - response = Response(status.HTTP_204_NO_CONTENT) - - # Pre-serialize filtering (eg filter complex objects into natively serializable types) - response.cleaned_content = self.resource.object_to_serializable(response.raw_content) + self.request = request + self.args = args + self.kwargs = kwargs - except ErrorResponse, exc: - response = exc.response + # 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) + + try: + # If using a form POST with '_method'/'_content'/'_content_type' overrides, then alter + # self.method, self.content_type, self.RAW_CONTENT & self.CONTENT appropriately. + self.perform_form_overloading() + + # Authenticate and check request is has the relevant permissions + self._check_permissions() + + # Get the appropriate handler method + if self.method.lower() in self.http_method_names: + handler = getattr(self, self.method.lower(), self.http_method_not_allowed) + else: + handler = self.http_method_not_allowed + + response_obj = handler(request, *args, **kwargs) + + # Allow return value to be either Response, or an object, or None + if isinstance(response_obj, Response): + response = response_obj + elif response_obj is not None: + response = Response(status.HTTP_200_OK, response_obj) + else: + response = Response(status.HTTP_204_NO_CONTENT) + + # Pre-serialize filtering (eg filter complex objects into natively serializable types) + response.cleaned_content = self.resource.object_to_serializable(response.raw_content) + + except ErrorResponse, exc: + response = exc.response + except: + import traceback + traceback.print_exc() + + # Always add these headers. + # + # TODO - this isn't actually the correct way to set the vary header, + # also it's currently sub-obtimal for HTTP caching - need to sort that out. + response.headers['Allow'] = ', '.join(self.allowed_methods) + response.headers['Vary'] = 'Authenticate, Accept' + + return self.render(response) except: import traceback traceback.print_exc() - - # Always add these headers. - # - # TODO - this isn't actually the correct way to set the vary header, - # also it's currently sub-obtimal for HTTP caching - need to sort that out. - response.headers['Allow'] = ', '.join(self.allowed_methods) - response.headers['Vary'] = 'Authenticate, Accept' - - return self.render(response) From 144d52c7b330174269db3a3d1c5b27e0765f47df Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 10 May 2011 12:28:11 +0100 Subject: [PATCH 34/70] Rename resource to view in few remaining places in renderers (because that's what it now is) --- djangorestframework/mixins.py | 8 ++---- djangorestframework/renderers.py | 44 ++++++++++++++++++-------------- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 0a45ef08c..ab290ffff 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -268,18 +268,14 @@ class ResponseMixin(object): response = exc.response # Serialize the response content + # TODO: renderer.media_type isn't the right thing to do here... if response.has_content_body: content = renderer(self).render(response.cleaned_content, renderer.media_type) else: content = renderer(self).render() - - # 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 renderer.mimetype is underspecified, or if a content-type header has been set + # TODO: renderer.media_type isn't the right thing to do here... resp = HttpResponse(content, mimetype=renderer.media_type, status=response.status) for (key, val) in response.headers.items(): resp[key] = val diff --git a/djangorestframework/renderers.py b/djangorestframework/renderers.py index 6c3d23e29..d18a8a373 100644 --- a/djangorestframework/renderers.py +++ b/djangorestframework/renderers.py @@ -78,26 +78,26 @@ class DocumentingTemplateRenderer(BaseRenderer): """ template = None - def _get_content(self, resource, request, obj, media_type): + def _get_content(self, view, request, obj, media_type): """Get the content as if it had been rendered by a non-documenting renderer. (Typically this will be the content as it would have been if the Resource had been requested with an 'Accept: */*' header, although with verbose style formatting if appropriate.)""" # Find the first valid renderer and render the content. (Don't use another documenting renderer.) - renderers = [renderer for renderer in resource.renderers if not isinstance(renderer, DocumentingTemplateRenderer)] + renderers = [renderer for renderer in view.renderers if not isinstance(renderer, DocumentingTemplateRenderer)] if not renderers: return '[No renderers were found]' media_type = add_media_type_param(media_type, 'indent', '4') - content = renderers[0](resource).render(obj, media_type) + content = renderers[0](view).render(obj, media_type) if not all(char in string.printable for char in content): return '[%d bytes of binary content]' return content - def _get_form_instance(self, resource): + def _get_form_instance(self, view): """Get a form, possibly bound to either the input or output data. In the absence on of the Resource having an associated form then provide a form that can be used to submit arbitrary content.""" @@ -105,13 +105,13 @@ class DocumentingTemplateRenderer(BaseRenderer): #form_instance = resource.form_instance # TODO! Reinstate this - form_instance = getattr(resource, 'bound_form_instance', None) + form_instance = getattr(view, 'bound_form_instance', None) - if not form_instance and hasattr(resource, 'get_bound_form'): + if not form_instance and hasattr(view, 'get_bound_form'): # Otherwise if we have a response that is valid against the form then use that - if resource.response.has_content_body: + if view.response.has_content_body: try: - form_instance = resource.get_bound_form(resource.response.cleaned_content) + form_instance = view.get_bound_form(view.response.cleaned_content) if form_instance and not form_instance.is_valid(): form_instance = None except: @@ -120,41 +120,41 @@ class DocumentingTemplateRenderer(BaseRenderer): # If we still don't have a form instance then try to get an unbound form if not form_instance: try: - form_instance = resource.get_bound_form() + form_instance = view.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: - form_instance = self._get_generic_content_form(resource) + form_instance = self._get_generic_content_form(view) return form_instance - def _get_generic_content_form(self, resource): + def _get_generic_content_form(self, view): """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 getattr(resource, 'USE_FORM_OVERLOADING', False): + # as the view won't treat the form's value as the content of the request. + if not getattr(view, 'USE_FORM_OVERLOADING', False): return None # NB. http://jacobian.org/writing/dynamic-form-generation/ class GenericContentForm(forms.Form): - def __init__(self, resource): + def __init__(self, view): """We don't know the names of the fields we want to set until the point the form is instantiated, as they are determined by the Resource the form is being created against. Add the fields dynamically.""" super(GenericContentForm, self).__init__() - contenttype_choices = [(media_type, media_type) for media_type in resource.parsed_media_types] - initial_contenttype = resource.default_parser.media_type + contenttype_choices = [(media_type, media_type) for media_type in view.parsed_media_types] + initial_contenttype = view.default_parser.media_type - self.fields[resource.CONTENTTYPE_PARAM] = forms.ChoiceField(label='Content Type', + self.fields[view.CONTENTTYPE_PARAM] = forms.ChoiceField(label='Content Type', choices=contenttype_choices, initial=initial_contenttype) - self.fields[resource.CONTENT_PARAM] = forms.CharField(label='Content', + self.fields[view.CONTENT_PARAM] = forms.CharField(label='Content', widget=forms.Textarea) # If either of these reserved parameters are turned off then content tunneling is not possible @@ -162,7 +162,7 @@ class DocumentingTemplateRenderer(BaseRenderer): return None # Okey doke, let's do it - return GenericContentForm(resource) + return GenericContentForm(view) def render(self, obj=None, media_type=None): @@ -206,6 +206,12 @@ class DocumentingTemplateRenderer(BaseRenderer): 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.view.response.status == 204: + self.view.response.status = 200 + return ret From cc4c9a9518bda227df78e511242172773358c95c Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 10 May 2011 12:51:49 +0100 Subject: [PATCH 35/70] Bits of cleanup --- djangorestframework/mixins.py | 38 +++++++++++++++++++------------- djangorestframework/renderers.py | 26 +++++++++++++--------- 2 files changed, 39 insertions(+), 25 deletions(-) diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index ab290ffff..99f6719b3 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -4,7 +4,7 @@ from django.contrib.auth.models import AnonymousUser from django.db.models.query import QuerySet from django.db.models.fields.related import RelatedField from django.http import HttpResponse -from django.http.multipartparser import LimitBytes # TODO: Use LimitedStream in compat +from django.http.multipartparser import LimitBytes from djangorestframework import status from djangorestframework.parsers import FormParser, MultiPartParser @@ -87,7 +87,8 @@ class RequestMixin(object): content_length = int(request.META.get('CONTENT_LENGTH', request.META.get('HTTP_CONTENT_LENGTH'))) except (ValueError, TypeError): content_length = 0 - + + # TODO: Add 1.3's LimitedStream to compat and use that. # Currently only supports parsing request body as a stream with 1.3 if content_length == 0: return None @@ -116,6 +117,8 @@ class RequestMixin(object): #except (ValueError, TypeError): # content_length = 0 # self._stream = LimitedStream(request, content_length) + # + # UPDATE: http://code.djangoproject.com/ticket/15785 self._stream = request else: self._stream = StringIO(request.raw_post_data) @@ -227,7 +230,7 @@ class RequestMixin(object): @property def default_parser(self): """Return the view's most preferred renderer. - (This has no behavioural effect, but is may be used by documenting renderers)""" + (This has no behavioral effect, but is may be used by documenting renderers)""" return self.parsers[0] @@ -244,17 +247,17 @@ class ResponseMixin(object): """ Adds behavior for pluggable Renderers to a :class:`.BaseView` or Django :class:`View`. class. - Default behaviour is to use standard HTTP Accept header content negotiation. - Also supports overidding the content type by specifying an _accept= parameter in the URL. + Default behavior is to use standard HTTP Accept header content negotiation. + Also supports overriding the content type by specifying an _accept= parameter in the URL. Ignores Accept headers from Internet Explorer user agents and uses a sensible browser Accept header instead. """ - ACCEPT_QUERY_PARAM = '_accept' # Allow override of Accept header in URL query params REWRITE_IE_ACCEPT_HEADER = True renderers = () - - + + # TODO: wrap this behavior around dispatch(), ensuring it works well with + # existing Django classes that use render_to_response. def render(self, response): """ Takes a ``Response`` object and returns an ``HttpResponse``. @@ -284,10 +287,12 @@ class ResponseMixin(object): def _determine_renderer(self, request): - """Return the appropriate renderer for the output, given the client's 'Accept' header, + """ + Return the appropriate renderer for the output, given the client's 'Accept' header, and the content types that this mixin knows how to serve. - See: RFC 2616, Section 14 - http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html""" + 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 @@ -351,13 +356,17 @@ class ResponseMixin(object): @property def renderted_media_types(self): - """Return an list of all the media types that this resource can render.""" + """ + Return an list of all the media types that this resource can render. + """ return [renderer.media_type for renderer in self.renderers] @property def default_renderer(self): - """Return the resource's most preferred renderer. - (This renderer is used if the client does not send and Accept: header, or sends Accept: */*)""" + """ + Return the resource's most preferred renderer. + (This renderer is used if the client does not send and Accept: header, or sends Accept: */*) + """ return self.renderers[0] @@ -367,8 +376,6 @@ class AuthMixin(object): """ Simple mixin class to provide authentication and permission checking, by adding a set of authentication and permission classes on a ``View``. - - TODO: wrap this behavior around dispatch() """ authentication = () permissions = () @@ -391,6 +398,7 @@ class AuthMixin(object): return user return AnonymousUser() + # TODO: wrap this behavior around dispatch() def _check_permissions(self): """ Check user permissions and either raise an ``ErrorResponse`` or return. diff --git a/djangorestframework/renderers.py b/djangorestframework/renderers.py index d18a8a373..73809b5ed 100644 --- a/djangorestframework/renderers.py +++ b/djangorestframework/renderers.py @@ -1,8 +1,9 @@ """ Renderers are used to serialize a View's output into specific media types. -django-rest-framework also provides HTML and PlainText renderers that help self-document the API, -by serializing the output along with documentation regarding the Resource, output status and headers, -and providing forms and links depending on the allowed methods, renderers and parsers on the Resource. + +Django REST framework also provides HTML and PlainText renderers that help self-document the API, +by serializing the output along with documentation regarding the View, output status and headers, +and providing forms and links depending on the allowed methods, renderers and parsers on the View. """ from django import forms from django.conf import settings @@ -33,8 +34,8 @@ __all__ = ( class BaseRenderer(object): """ - All renderers must extend this class, set the media_type attribute, and - override the render() function. + All renderers must extend this class, set the media_type attribute, + and override the render() function. """ media_type = None @@ -43,13 +44,20 @@ class BaseRenderer(object): def render(self, obj=None, media_type=None): """ + Given an object render it into a string. + + The requested media type is also passed to this method, + as it may contain parameters relevant to how the parser + should render the output. + EG: 'application/json; indent=4' + By default render simply returns the ouput as-is. Override this method to provide for other behavior. """ if obj is None: return '' - return obj + return str(obj) class TemplateRenderer(BaseRenderer): @@ -101,10 +109,8 @@ class DocumentingTemplateRenderer(BaseRenderer): """Get a form, possibly bound to either the input or output data. 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 - # TODO! Reinstate this + # Get the form instance if we have one bound to the input form_instance = getattr(view, 'bound_form_instance', None) if not form_instance and hasattr(view, 'get_bound_form'): @@ -183,7 +189,7 @@ class DocumentingTemplateRenderer(BaseRenderer): if apply_markdown: try: markeddown = apply_markdown(description) - except AttributeError: # TODO: possibly split the get_description / get_name into a mixin class + except AttributeError: markeddown = None breadcrumb_list = get_breadcrumbs(self.view.request.path) From a2575c1191104df024b41c58825b9a24d4c4ae2d Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 10 May 2011 12:59:13 +0100 Subject: [PATCH 36/70] Bits of cleanup --- djangorestframework/mixins.py | 12 ++++++++---- djangorestframework/resource.py | 3 ++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 99f6719b3..b80f5894c 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -150,7 +150,9 @@ class RequestMixin(object): return self._content - + # TODO: Modify this so that it happens implictly, rather than being called explicitly + # ie accessing any of .DATA, .FILES, .content_type, .stream or .method will force + # form overloading. def perform_form_overloading(self): """ Check the request to see if it is using form POST '_method'/'_content'/'_content_type' overrides. @@ -199,6 +201,7 @@ class RequestMixin(object): content_type}) + # TODO: Acutally this needs to go into Resource def validate(self, content): """ Validate, cleanup, and type-ify the request content. @@ -209,6 +212,7 @@ class RequestMixin(object): return content + # TODO: Acutally this needs to go into Resource def get_bound_form(self, content=None): """ Return a bound form instance for the given content, @@ -229,7 +233,7 @@ class RequestMixin(object): @property def default_parser(self): - """Return the view's most preferred renderer. + """Return the view's most preferred parser. (This has no behavioral effect, but is may be used by documenting renderers)""" return self.parsers[0] @@ -256,8 +260,8 @@ class ResponseMixin(object): renderers = () - # TODO: wrap this behavior around dispatch(), ensuring it works well with - # existing Django classes that use render_to_response. + # TODO: wrap this behavior around dispatch(), ensuring it works + # out of the box with existing Django classes that use render_to_response. def render(self, response): """ Takes a ``Response`` object and returns an ``HttpResponse``. diff --git a/djangorestframework/resource.py b/djangorestframework/resource.py index 044424987..6b039059f 100644 --- a/djangorestframework/resource.py +++ b/djangorestframework/resource.py @@ -6,6 +6,7 @@ import decimal import inspect import re +# TODO: Replace this with new Serializer code based on Forms API. class Resource(object): """A Resource determines how an object maps to a serializable entity. @@ -61,7 +62,7 @@ class Resource(object): if inspect.ismethod(f) and len(inspect.getargspec(f)[0]) == 1: ret = _any(f()) else: - ret = unicode(thing) # TRC TODO: Change this back! + ret = unicode(thing) # TRC return ret From 4d126796752cc3c79a24fd9caed49da6c525096f Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 10 May 2011 16:01:58 +0100 Subject: [PATCH 37/70] More tests, getting new serialization into resource --- djangorestframework/authentication.py | 4 +- djangorestframework/mixins.py | 23 ++++-- djangorestframework/renderers.py | 25 ++++--- djangorestframework/resource.py | 81 ++++++++++++++++++++- djangorestframework/response.py | 19 +++-- djangorestframework/status.py | 6 +- djangorestframework/templates/renderer.html | 2 +- djangorestframework/tests/resource.py | 31 ++++++++ djangorestframework/validators.py | 18 +++-- 9 files changed, 173 insertions(+), 36 deletions(-) create mode 100644 djangorestframework/tests/resource.py diff --git a/djangorestframework/authentication.py b/djangorestframework/authentication.py index dea19f916..97e5d9c5a 100644 --- a/djangorestframework/authentication.py +++ b/djangorestframework/authentication.py @@ -3,7 +3,7 @@ The ``authentication`` module provides a set of pluggable authentication classes Authentication behavior is provided by adding the ``AuthMixin`` class to a ``View`` . -The set of authentication methods which are used is then specified by setting +The set of authentication methods which are used is then specified by setting the ``authentication`` attribute on the ``View`` class, and listing a set of authentication classes. """ @@ -81,7 +81,7 @@ class UserLoggedInAuthenticaton(BaseAuthenticaton): """ def authenticate(self, request): - # TODO: Switch this back to request.POST, and let MultiPartParser deal with the consequences. + # TODO: Switch this back to request.POST, and let FormParser/MultiPartParser deal with the consequences. if getattr(request, 'user', None) and request.user.is_active: # If this is a POST request we enforce CSRF validation. if request.method.upper() == 'POST': diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index b80f5894c..65ebe1715 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -33,7 +33,7 @@ __all__ = ( class RequestMixin(object): """ - Mixin class to provide request parsing behaviour. + Mixin class to provide request parsing behavior. """ USE_FORM_OVERLOADING = True @@ -93,6 +93,13 @@ class RequestMixin(object): if content_length == 0: return None elif hasattr(request, 'read'): + # UPDATE BASED ON COMMENT BELOW: + # + # Yup, this was a bug in Django - fixed and waiting check in - see ticket 15785. + # http://code.djangoproject.com/ticket/15785 + # + # COMMENT: + # # It's not at all clear if this needs to be byte limited or not. # Maybe I'm just being dumb but it looks to me like there's some issues # with that in Django. @@ -117,8 +124,6 @@ class RequestMixin(object): #except (ValueError, TypeError): # content_length = 0 # self._stream = LimitedStream(request, content_length) - # - # UPDATE: http://code.djangoproject.com/ticket/15785 self._stream = request else: self._stream = StringIO(request.raw_post_data) @@ -290,11 +295,15 @@ class ResponseMixin(object): return resp + # TODO: This should be simpler now. + # Add a handles_response() to the renderer, then iterate through the + # acceptable media types, ordered by how specific they are, + # calling handles_response on each renderer. def _determine_renderer(self, request): """ Return the appropriate renderer for the output, given the client's 'Accept' header, and the content types that this mixin knows how to serve. - + See: RFC 2616, Section 14 - http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html """ @@ -321,7 +330,7 @@ class ResponseMixin(object): qvalue = Decimal('1.0') if len(components) > 1: - # Parse items that have a qvalue eg text/html;q=0.9 + # Parse items that have a qvalue eg 'text/html; q=0.9' try: (q, num) = components[-1].split('=') if q == 'q': @@ -356,10 +365,10 @@ class ResponseMixin(object): raise ErrorResponse(status.HTTP_406_NOT_ACCEPTABLE, {'detail': 'Could not satisfy the client\'s Accept header', - 'available_types': self.renderted_media_types}) + 'available_types': self.rendered_media_types}) @property - def renderted_media_types(self): + def rendered_media_types(self): """ Return an list of all the media types that this resource can render. """ diff --git a/djangorestframework/renderers.py b/djangorestframework/renderers.py index 73809b5ed..bda2d38e9 100644 --- a/djangorestframework/renderers.py +++ b/djangorestframework/renderers.py @@ -24,6 +24,7 @@ from urllib import quote_plus __all__ = ( 'BaseRenderer', + 'TemplateRenderer', 'JSONRenderer', 'DocumentingHTMLRenderer', 'DocumentingXHTMLRenderer', @@ -87,10 +88,12 @@ class DocumentingTemplateRenderer(BaseRenderer): template = None def _get_content(self, view, request, obj, media_type): - """Get the content as if it had been rendered by a non-documenting renderer. + """ + Get the content as if it had been rendered by a non-documenting renderer. (Typically this will be the content as it would have been if the Resource had been - requested with an 'Accept: */*' header, although with verbose style formatting if appropriate.)""" + requested with an 'Accept: */*' header, although with verbose style formatting if appropriate.) + """ # Find the first valid renderer and render the content. (Don't use another documenting renderer.) renderers = [renderer for renderer in view.renderers if not isinstance(renderer, DocumentingTemplateRenderer)] @@ -103,12 +106,14 @@ class DocumentingTemplateRenderer(BaseRenderer): return '[%d bytes of binary content]' return content - + def _get_form_instance(self, view): - """Get a form, possibly bound to either the input or output data. + """ + Get a form, possibly bound to either the input or output data. In the absence on of the Resource having an associated form then - provide a form that can be used to submit arbitrary content.""" + 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 = getattr(view, 'bound_form_instance', None) @@ -138,8 +143,10 @@ class DocumentingTemplateRenderer(BaseRenderer): def _get_generic_content_form(self, view): - """Returns a form that allows for arbitrary content types to be tunneled via standard HTML forms - (Which are typically application/x-www-form-urlencoded)""" + """ + 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 view won't treat the form's value as the content of the request. @@ -197,8 +204,8 @@ class DocumentingTemplateRenderer(BaseRenderer): template = loader.get_template(self.template) context = RequestContext(self.view.request, { 'content': content, - 'resource': self.view, - 'request': self.view.request, + 'resource': self.view, # TODO: rename to view + 'request': self.view.request, # TODO: remove 'response': self.view.response, 'description': description, 'name': name, diff --git a/djangorestframework/resource.py b/djangorestframework/resource.py index 6b039059f..441786842 100644 --- a/djangorestframework/resource.py +++ b/djangorestframework/resource.py @@ -1,16 +1,89 @@ -from django.db.models import Model +from django.db import models from django.db.models.query import QuerySet from django.db.models.fields.related import RelatedField +from django.utils.encoding import smart_unicode import decimal import inspect import re + + +def _model_to_dict(instance, fields=None, exclude=None): + """ + This is a clone of Django's ``django.forms.model_to_dict`` except that it + doesn't coerce related objects into primary keys. + """ + opts = instance._meta + data = {} + for f in opts.fields + opts.many_to_many: + if not f.editable: + continue + if fields and not f.name in fields: + continue + if exclude and f.name in exclude: + continue + if isinstance(f, models.ForeignKey): + data[f.name] = getattr(instance, f.name) + else: + data[f.name] = f.value_from_object(instance) + return data + + +def _object_to_data(obj): + """ + Convert an object into a serializable representation. + """ + if isinstance(obj, dict): + # dictionaries + return dict([ (key, _object_to_data(val)) for key, val in obj.iteritems() ]) + if isinstance(obj, (tuple, list, set, QuerySet)): + # basic iterables + return [_object_to_data(item) for item in obj] + if isinstance(obj, models.Manager): + # Manager objects + ret = [_object_to_data(item) for item in obj.all()] + if isinstance(obj, models.Model): + # Model instances + return _object_to_data(_model_to_dict(obj)) + if isinstance(obj, decimal.Decimal): + # Decimals (force to string representation) + return str(obj) + if inspect.isfunction(obj) and not inspect.getargspec(obj)[0]: + # function with no args + return _object_to_data(obj()) + if inspect.ismethod(obj) and len(inspect.getargspec(obj)[0]) == 1: + # method with only a 'self' args + return _object_to_data(obj()) + + # fallback + return smart_unicode(obj, strings_only=True) + + # TODO: Replace this with new Serializer code based on Forms API. +#class Resource(object): +# def __init__(self, view): +# self.view = view +# +# def object_to_data(self, obj): +# pass +# +# def data_to_object(self, data, files): +# pass +# +#class FormResource(object): +# pass +# +#class ModelResource(object): +# pass + + class Resource(object): - """A Resource determines how an object maps to a serializable entity. - Objects that a resource can act on include plain Python object instances, Django Models, and Django QuerySets.""" + """ + A Resource determines how a python object maps to some serializable data. + Objects that a resource can act on include plain Python object instances, Django Models, and Django QuerySets. + """ # The model attribute refers to the Django Model which this Resource maps to. # (The Model's class, rather than an instance of the Model) @@ -50,7 +123,7 @@ class Resource(object): ret = thing elif isinstance(thing, decimal.Decimal): ret = str(thing) - elif isinstance(thing, Model): + elif isinstance(thing, models.Model): ret = _model(thing, fields=fields) #elif isinstance(thing, HttpResponse): TRC # raise HttpStatusCode(thing) diff --git a/djangorestframework/response.py b/djangorestframework/response.py index 9b3c5851b..72bc16c8b 100644 --- a/djangorestframework/response.py +++ b/djangorestframework/response.py @@ -1,11 +1,14 @@ from django.core.handlers.wsgi import STATUS_CODE_TEXT -__all__ =['Response', 'ErrorResponse'] +__all__ = ('Response', 'ErrorResponse') # TODO: remove raw_content/cleaned_content and just use content? class Response(object): - """An HttpResponse that may include content that hasn't yet been serialized.""" + """ + An HttpResponse that may include content that hasn't yet been serialized. + """ + def __init__(self, status=200, content=None, headers={}): self.status = status self.has_content_body = content is not None @@ -15,12 +18,18 @@ class Response(object): @property def status_text(self): - """Return reason text corresponding to our HTTP response status code. - Provided for convenience.""" + """ + Return reason text corresponding to our HTTP response status code. + Provided for convenience. + """ return STATUS_CODE_TEXT.get(self.status, '') class ErrorResponse(BaseException): - """An exception representing an HttpResponse that should be returned immediately.""" + """ + An exception representing an Response that should be returned immediately. + Any content should be serialized as-is, without being filtered. + """ + def __init__(self, status, content=None, headers={}): self.response = Response(status, content=content, headers=headers) diff --git a/djangorestframework/status.py b/djangorestframework/status.py index 8e95f1e4b..67704b8e9 100644 --- a/djangorestframework/status.py +++ b/djangorestframework/status.py @@ -1,7 +1,9 @@ -"""Descriptive HTTP status codes, for code readability. +""" +Descriptive HTTP status codes, for code readability. See RFC 2616 - Sec 10: http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html -Also, django.core.handlers.wsgi.STATUS_CODE_TEXT""" +Also see django.core.handlers.wsgi.STATUS_CODE_TEXT +""" # Verbose format HTTP_100_CONTINUE = 100 diff --git a/djangorestframework/templates/renderer.html b/djangorestframework/templates/renderer.html index 105ea0a2d..e213ecfa9 100644 --- a/djangorestframework/templates/renderer.html +++ b/djangorestframework/templates/renderer.html @@ -48,7 +48,7 @@

GET {{ name }}

GET - {% for media_type in resource.renderted_media_types %} + {% for media_type in resource.rendered_media_types %} {% with resource.ACCEPT_QUERY_PARAM|add:"="|add:media_type as param %} [{{ media_type }}] {% endwith %} diff --git a/djangorestframework/tests/resource.py b/djangorestframework/tests/resource.py new file mode 100644 index 000000000..0ed41951b --- /dev/null +++ b/djangorestframework/tests/resource.py @@ -0,0 +1,31 @@ +"""Tests for the resource module""" +from django.test import TestCase +from djangorestframework.resource import _object_to_data + +import datetime +import decimal + +class TestObjectToData(TestCase): + """Tests for the _object_to_data function""" + + def test_decimal(self): + """Decimals need to be converted to a string representation.""" + self.assertEquals(_object_to_data(decimal.Decimal('1.5')), '1.5') + + def test_function(self): + """Functions with no arguments should be called.""" + def foo(): + return 1 + self.assertEquals(_object_to_data(foo), 1) + + def test_method(self): + """Methods with only a ``self`` argument should be called.""" + class Foo(object): + def foo(self): + return 1 + self.assertEquals(_object_to_data(Foo().foo), 1) + + def test_datetime(self): + """datetime objects are left as-is.""" + now = datetime.datetime.now() + self.assertEquals(_object_to_data(now), now) \ No newline at end of file diff --git a/djangorestframework/validators.py b/djangorestframework/validators.py index d806a6146..bef85af77 100644 --- a/djangorestframework/validators.py +++ b/djangorestframework/validators.py @@ -31,20 +31,24 @@ class FormValidator(BaseValidator): def validate(self, content): - """Given some content as input return some cleaned, validated content. + """ + Given some content as input return some cleaned, validated content. Raises a ErrorResponse 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 ErrorResponse 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}.""" + 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, allowed_extra_fields=()): - """Wrapped by validate to hide the extra_fields option that the ModelValidatorMixin uses. + """ + 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.""" + expect to see on the input. + """ bound_form = self.get_bound_form(content) if bound_form is None: @@ -138,7 +142,8 @@ class ModelFormValidator(FormValidator): # 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. + """ + Given some content as input return some cleaned, validated content. Raises a ErrorResponse with status code 400 (Bad Request) on failure. Validation is standard form or model form validation, @@ -148,7 +153,8 @@ class ModelFormValidator(FormValidator): On failure the ErrorResponse 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}.""" + If the 'field-errors' key exists it is a dict of {field name as string: list of errors as strings}. + """ return self._validate(content, allowed_extra_fields=self._property_fields_set) From 15f9e7c56699d31043782045a9fe47c354f612cb Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 12 May 2011 12:55:13 +0100 Subject: [PATCH 38/70] refactoring resource specfic stuff into ResourceMixin - validators now defunct --- djangorestframework/authentication.py | 4 +- djangorestframework/mixins.py | 126 ++++++------ djangorestframework/parsers.py | 19 +- djangorestframework/renderers.py | 14 +- djangorestframework/resource.py | 275 +++++++++++++++++++++++--- djangorestframework/tests/content.py | 30 +-- djangorestframework/tests/files.py | 6 +- djangorestframework/tests/methods.py | 4 +- djangorestframework/tests/parsers.py | 18 +- djangorestframework/views.py | 25 +-- examples/sandbox/views.py | 4 +- 11 files changed, 373 insertions(+), 152 deletions(-) diff --git a/djangorestframework/authentication.py b/djangorestframework/authentication.py index 97e5d9c5a..b0ba41aae 100644 --- a/djangorestframework/authentication.py +++ b/djangorestframework/authentication.py @@ -85,9 +85,9 @@ class UserLoggedInAuthenticaton(BaseAuthenticaton): if getattr(request, 'user', None) and request.user.is_active: # If this is a POST request we enforce CSRF validation. if request.method.upper() == 'POST': - # Temporarily replace request.POST with .RAW_CONTENT, + # Temporarily replace request.POST with .DATA, # so that we use our more generic request parsing - request._post = self.view.RAW_CONTENT + request._post = self.view.DATA resp = CsrfViewMiddleware().process_view(request, None, (), {}) del(request._post) if resp is not None: # csrf failed diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 65ebe1715..d1c83c177 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -1,4 +1,6 @@ -"""""" +""" +The mixins module provides a set of reusable mixin classes that can be added to a ``View``. +""" from django.contrib.auth.models import AnonymousUser from django.db.models.query import QuerySet @@ -18,9 +20,12 @@ from StringIO import StringIO __all__ = ( + # Base behavior mixins 'RequestMixin', 'ResponseMixin', 'AuthMixin', + 'ResourceMixin', + # Model behavior mixins 'ReadModelMixin', 'CreateModelMixin', 'UpdateModelMixin', @@ -36,13 +41,12 @@ class RequestMixin(object): Mixin class to provide request parsing behavior. """ - USE_FORM_OVERLOADING = True - METHOD_PARAM = "_method" - CONTENTTYPE_PARAM = "_content_type" - CONTENT_PARAM = "_content" + _USE_FORM_OVERLOADING = True + _METHOD_PARAM = '_method' + _CONTENTTYPE_PARAM = '_content_type' + _CONTENT_PARAM = '_content' parsers = () - validators = () def _get_method(self): """ @@ -137,62 +141,58 @@ class RequestMixin(object): self._stream = stream - def _get_raw_content(self): - """ - Returns the parsed content of the request - """ - if not hasattr(self, '_raw_content'): - self._raw_content = self.parse(self.stream, self.content_type) - return self._raw_content + def _load_data_and_files(self): + (self._data, self._files) = self._parse(self.stream, self.content_type) + def _get_data(self): + if not hasattr(self, '_data'): + self._load_data_and_files() + return self._data - def _get_content(self): - """ - Returns the parsed and validated content of the request - """ - if not hasattr(self, '_content'): - self._content = self.validate(self.RAW_CONTENT) + def _get_files(self): + if not hasattr(self, '_files'): + self._load_data_and_files() + return self._files - return self._content # TODO: Modify this so that it happens implictly, rather than being called explicitly - # ie accessing any of .DATA, .FILES, .content_type, .stream or .method will force + # ie accessing any of .DATA, .FILES, .content_type, .method will force # form overloading. - def perform_form_overloading(self): + def _perform_form_overloading(self): """ Check the request to see if it is using form POST '_method'/'_content'/'_content_type' overrides. If it is then alter self.method, self.content_type, self.CONTENT to reflect that rather than simply delegating them to the original request. """ - if not self.USE_FORM_OVERLOADING or self.method != 'POST' or not is_form_media_type(self.content_type): + if not self._USE_FORM_OVERLOADING or self.method != 'POST' or not is_form_media_type(self.content_type): return # Temporarily switch to using the form parsers, then parse the content parsers = self.parsers self.parsers = (FormParser, MultiPartParser) - content = self.RAW_CONTENT + content = self.DATA self.parsers = parsers # Method overloading - change the method and remove the param from the content - if self.METHOD_PARAM in content: - self.method = content[self.METHOD_PARAM].upper() - del self._raw_content[self.METHOD_PARAM] + if self._METHOD_PARAM in content: + self.method = content[self._METHOD_PARAM].upper() + del self._data[self._METHOD_PARAM] # Content overloading - rewind the stream and modify the content type - if self.CONTENT_PARAM in content and self.CONTENTTYPE_PARAM in content: - self._content_type = content[self.CONTENTTYPE_PARAM] - self._stream = StringIO(content[self.CONTENT_PARAM]) - del(self._raw_content) + if self._CONTENT_PARAM in content and self._CONTENTTYPE_PARAM in content: + self._content_type = content[self._CONTENTTYPE_PARAM] + self._stream = StringIO(content[self._CONTENT_PARAM]) + del(self._data) - def parse(self, stream, content_type): + def _parse(self, stream, content_type): """ Parse the request content. May raise a 415 ErrorResponse (Unsupported Media Type), or a 400 ErrorResponse (Bad Request). """ if stream is None or content_type is None: - return None + return (None, None) parsers = as_tuple(self.parsers) @@ -206,48 +206,28 @@ class RequestMixin(object): content_type}) - # TODO: Acutally this needs to go into Resource - def validate(self, content): - """ - Validate, cleanup, and type-ify the request content. - """ - for validator_cls in self.validators: - validator = validator_cls(self) - content = validator.validate(content) - return content - - - # TODO: Acutally this needs to go into Resource - def get_bound_form(self, content=None): - """ - Return a bound form instance for the given content, - if there is an appropriate form validator attached to the view. - """ - for validator_cls in self.validators: - if hasattr(validator_cls, 'get_bound_form'): - validator = validator_cls(self) - return validator.get_bound_form(content) - return None - - @property def parsed_media_types(self): - """Return an list of all the media types that this view can parse.""" + """ + Return an list of all the media types that this view can parse. + """ return [parser.media_type for parser in self.parsers] @property def default_parser(self): - """Return the view's most preferred parser. - (This has no behavioral effect, but is may be used by documenting renderers)""" + """ + Return the view's most preferred parser. + (This has no behavioral effect, but is may be used by documenting renderers) + """ return self.parsers[0] method = property(_get_method, _set_method) content_type = property(_get_content_type, _set_content_type) stream = property(_get_stream, _set_stream) - RAW_CONTENT = property(_get_raw_content) - CONTENT = property(_get_content) + DATA = property(_get_data) + FILES = property(_get_files) ########## ResponseMixin ########## @@ -422,6 +402,28 @@ class AuthMixin(object): permission.check_permission(user) +########## Resource Mixin ########## + +class ResourceMixin(object): + @property + def CONTENT(self): + if not hasattr(self, '_content'): + self._content = self._get_content(self.DATA, self.FILES) + return self._content + + def _get_content(self, data, files): + resource = self.resource(self) + return resource.validate(data, files) + + def get_bound_form(self, content=None): + resource = self.resource(self) + return resource.get_bound_form(content) + + def object_to_data(self, obj): + resource = self.resource(self) + return resource.object_to_data(obj) + + ########## Model Mixins ########## class ReadModelMixin(object): diff --git a/djangorestframework/parsers.py b/djangorestframework/parsers.py index da700367d..9e1b971bb 100644 --- a/djangorestframework/parsers.py +++ b/djangorestframework/parsers.py @@ -41,7 +41,7 @@ class BaseParser(object): """ self.view = view - def can_handle_request(self, media_type): + def can_handle_request(self, content_type): """ Returns `True` if this parser is able to deal with the given media type. @@ -52,12 +52,12 @@ class BaseParser(object): This may be overridden to provide for other behavior, but typically you'll instead want to just set the ``media_type`` attribute on the class. """ - return media_type_matches(media_type, self.media_type) + return media_type_matches(content_type, self.media_type) def parse(self, stream): """ Given a stream to read from, return the deserialized output. - The return value may be of any type, but for many parsers it might typically be a dict-like object. + Should return a 2-tuple of (data, files). """ raise NotImplementedError("BaseParser.parse() Must be overridden to be implemented.") @@ -67,7 +67,7 @@ class JSONParser(BaseParser): def parse(self, stream): try: - return json.load(stream) + return (json.load(stream), None) except ValueError, exc: raise ErrorResponse(status.HTTP_400_BAD_REQUEST, {'detail': 'JSON parse error - %s' % unicode(exc)}) @@ -107,7 +107,7 @@ class PlainTextParser(BaseParser): media_type = 'text/plain' def parse(self, stream): - return stream.read() + return (stream.read(), None) class FormParser(BaseParser, DataFlatener): @@ -139,7 +139,7 @@ class FormParser(BaseParser, DataFlatener): if key in self.RESERVED_FORM_PARAMS: data.pop(key) - return data + return (data, None) def remove_empty_val(self, val_list): """ """ @@ -152,11 +152,6 @@ class FormParser(BaseParser, DataFlatener): val_list.pop(ind) -class MultipartData(dict): - def __init__(self, data, files): - dict.__init__(self, data) - self.FILES = files - class MultiPartParser(BaseParser, DataFlatener): media_type = 'multipart/form-data' RESERVED_FORM_PARAMS = ('csrfmiddlewaretoken',) @@ -175,4 +170,4 @@ class MultiPartParser(BaseParser, DataFlatener): if key in self.RESERVED_FORM_PARAMS: data.pop(key) - return MultipartData(data, files) + return (data, files) diff --git a/djangorestframework/renderers.py b/djangorestframework/renderers.py index bda2d38e9..0aa30f707 100644 --- a/djangorestframework/renderers.py +++ b/djangorestframework/renderers.py @@ -150,7 +150,7 @@ class DocumentingTemplateRenderer(BaseRenderer): # If we're not using content overloading there's no point in supplying a generic form, # as the view won't treat the form's value as the content of the request. - if not getattr(view, 'USE_FORM_OVERLOADING', False): + if not getattr(view, '_USE_FORM_OVERLOADING', False): return None # NB. http://jacobian.org/writing/dynamic-form-generation/ @@ -164,14 +164,14 @@ class DocumentingTemplateRenderer(BaseRenderer): contenttype_choices = [(media_type, media_type) for media_type in view.parsed_media_types] initial_contenttype = view.default_parser.media_type - self.fields[view.CONTENTTYPE_PARAM] = forms.ChoiceField(label='Content Type', - choices=contenttype_choices, - initial=initial_contenttype) - self.fields[view.CONTENT_PARAM] = forms.CharField(label='Content', - widget=forms.Textarea) + self.fields[view._CONTENTTYPE_PARAM] = forms.ChoiceField(label='Content Type', + choices=contenttype_choices, + initial=initial_contenttype) + self.fields[view._CONTENT_PARAM] = forms.CharField(label='Content', + widget=forms.Textarea) # If either of these reserved parameters are turned off then content tunneling is not possible - if self.view.CONTENTTYPE_PARAM is None or self.view.CONTENT_PARAM is None: + if self.view._CONTENTTYPE_PARAM is None or self.view._CONTENT_PARAM is None: return None # Okey doke, let's do it diff --git a/djangorestframework/resource.py b/djangorestframework/resource.py index 441786842..775d52889 100644 --- a/djangorestframework/resource.py +++ b/djangorestframework/resource.py @@ -42,13 +42,13 @@ def _object_to_data(obj): return [_object_to_data(item) for item in obj] if isinstance(obj, models.Manager): # Manager objects - ret = [_object_to_data(item) for item in obj.all()] + return [_object_to_data(item) for item in obj.all()] if isinstance(obj, models.Model): # Model instances return _object_to_data(_model_to_dict(obj)) if isinstance(obj, decimal.Decimal): # Decimals (force to string representation) - return str(obj) + return str(obj) if inspect.isfunction(obj) and not inspect.getargspec(obj)[0]: # function with no args return _object_to_data(obj()) @@ -60,26 +60,48 @@ def _object_to_data(obj): return smart_unicode(obj, strings_only=True) -# TODO: Replace this with new Serializer code based on Forms API. - -#class Resource(object): -# def __init__(self, view): -# self.view = view -# -# def object_to_data(self, obj): -# pass -# -# def data_to_object(self, data, files): -# pass -# -#class FormResource(object): -# pass -# -#class ModelResource(object): -# pass +def _form_to_data(form): + """ + Returns a dict containing the data in a form instance. + + This code is pretty much a clone of the ``Form.as_p()`` ``Form.as_ul`` + and ``Form.as_table()`` methods, except that it returns data suitable + for arbitrary serialization, rather than rendering the result directly + into html. + """ + ret = {} + for name, field in form.fields.items(): + if not form.is_bound: + data = form.initial.get(name, field.initial) + if callable(data): + data = data() + else: + if isinstance(field, FileField) and form.data is None: + data = form.initial.get(name, field.initial) + else: + data = field.widget.value_from_datadict(form.data, form.files, name) + ret[name] = field.prepare_value(data) + return ret -class Resource(object): +class BaseResource(object): + """Base class for all Resource classes, which simply defines the interface they provide.""" + + def __init__(self, view): + self.view = view + + def validate(self, data, files): + """Given some content as input return some cleaned, validated content. + Typically raises a ErrorResponse with status code 400 (Bad Request) on failure. + + Must be overridden to be implemented.""" + return data + + def object_to_data(self, obj): + return _object_to_data(obj) + + +class Resource(BaseResource): """ A Resource determines how a python object maps to some serializable data. Objects that a resource can act on include plain Python object instances, Django Models, and Django QuerySets. @@ -99,9 +121,11 @@ class Resource(object): # you should explicitly set the fields attribute on your class. fields = None - @classmethod - def object_to_serializable(self, data): - """A (horrible) munging of Piston's pre-serialization. Returns a dict""" + # TODO: Replace this with new Serializer code based on Forms API. + def object_to_data(self, obj): + """ + A (horrible) munging of Piston's pre-serialization. Returns a dict. + """ def _any(thing, fields=()): """ @@ -321,5 +345,208 @@ class Resource(object): return dict([ (k, _any(v)) for k, v in data.iteritems() ]) # Kickstart the seralizin'. - return _any(data, self.fields) + return _any(obj, self.fields) + +class FormResource(Resource): + """Validator class that uses forms for validation. + Also provides a get_bound_form() method which may be used by some renderers. + + The view class should provide `.form` attribute which specifies the form classmethod + to be used for validation. + + On calling validate() this validator may set a `.bound_form_instance` attribute on the + view, which may be used by some renderers.""" + + + def validate(self, data, files): + """ + Given some content as input return some cleaned, validated content. + Raises a ErrorResponse 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 ErrorResponse 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(data, files) + + + def _validate(self, data, files, 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. + """ + bound_form = self.get_bound_form(data, files) + + if bound_form is None: + return data + + self.view.bound_form_instance = bound_form + + seen_fields_set = set(data.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: + # Validation succeeded... + cleaned_data = bound_form.cleaned_data + + cleaned_data.update(bound_form.files) + + # 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] = data[key] + + return cleaned_data + + # Validation failed... + detail = {} + + if not bound_form.errors and not unknown_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.'] + + if field_errors: + detail[u'field-errors'] = field_errors + + # Return HTTP 400 response (BAD REQUEST) + raise ErrorResponse(400, detail) + + + def get_bound_form(self, data=None, files=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.""" + form_cls = getattr(self, 'form', None) + + if not form_cls: + return None + + if data is not None: + return form_cls(data, files) + + return form_cls() + + +class ModelResource(FormResource): + """Validator class that uses forms for validation and otherwise falls back to a model form if no form is set. + Also provides a get_bound_form() method which may be used by some renderers.""" + + """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. + + 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.) + def validate(self, data, files): + """ + Given some content as input return some cleaned, validated content. + Raises a ErrorResponse 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 ErrorResponse 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(data, files, allowed_extra_fields=self._property_fields_set) + + + def get_bound_form(self, data=None, files=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.""" + + form_cls = getattr(self, 'form', None) + model_cls = getattr(self, 'model', None) + + if form_cls: + # Use explict Form + return super(ModelFormValidator, self).get_bound_form(data, files) + + elif model_cls: + # Fall back to ModelForm which we create on the fly + class OnTheFlyModelForm(forms.ModelForm): + class Meta: + model = model_cls + #fields = tuple(self._model_fields_set) + + # Instantiate the ModelForm as appropriate + if content and isinstance(content, models.Model): + # Bound to an existing model instance + return OnTheFlyModelForm(instance=content) + elif not data is None: + return OnTheFlyModelForm(data, files) + return OnTheFlyModelForm() + + # Both form and model not set? Okay bruv, whatevs... + return None + + + @property + def _model_fields_set(self): + """Return a set containing the names of validated fields on the model.""" + resource = self.view.resource + model = getattr(resource, 'model', None) + fields = getattr(resource, 'fields', self.fields) + exclude_fields = getattr(resource, 'exclude_fields', self.exclude_fields) + + model_fields = set(field.name for field in model._meta.fields) + + if fields: + return model_fields & set(as_tuple(fields)) + + return model_fields - set(as_tuple(exclude_fields)) + + @property + def _property_fields_set(self): + """Returns a set containing the names of validated properties on the model.""" + resource = self.view.resource + model = getattr(resource, 'model', None) + fields = getattr(resource, 'fields', self.fields) + exclude_fields = getattr(resource, 'exclude_fields', self.exclude_fields) + + property_fields = set(attr for attr in dir(model) if + isinstance(getattr(model, attr, None), property) + and not attr.startswith('_')) + + if fields: + return property_fields & set(as_tuple(fields)) + + return property_fields - set(as_tuple(exclude_fields)) diff --git a/djangorestframework/tests/content.py b/djangorestframework/tests/content.py index e566ea009..a99981fd1 100644 --- a/djangorestframework/tests/content.py +++ b/djangorestframework/tests/content.py @@ -14,14 +14,14 @@ class TestContentParsing(TestCase): def ensure_determines_no_content_GET(self, view): """Ensure view.RAW_CONTENT returns None for GET request with no content.""" view.request = self.req.get('/') - self.assertEqual(view.RAW_CONTENT, None) + self.assertEqual(view.DATA, None) def ensure_determines_form_content_POST(self, view): """Ensure view.RAW_CONTENT returns content for POST request with form content.""" form_data = {'qwerty': 'uiop'} view.parsers = (FormParser, MultiPartParser) view.request = self.req.post('/', data=form_data) - self.assertEqual(view.RAW_CONTENT, form_data) + self.assertEqual(view.DATA, form_data) def ensure_determines_non_form_content_POST(self, view): """Ensure view.RAW_CONTENT returns content for POST request with non-form content.""" @@ -29,14 +29,14 @@ class TestContentParsing(TestCase): content_type = 'text/plain' view.parsers = (PlainTextParser,) view.request = self.req.post('/', content, content_type=content_type) - self.assertEqual(view.RAW_CONTENT, content) + self.assertEqual(view.DATA, content) def ensure_determines_form_content_PUT(self, view): """Ensure view.RAW_CONTENT returns content for PUT request with form content.""" form_data = {'qwerty': 'uiop'} view.parsers = (FormParser, MultiPartParser) view.request = self.req.put('/', data=form_data) - self.assertEqual(view.RAW_CONTENT, form_data) + self.assertEqual(view.DATA, form_data) def ensure_determines_non_form_content_PUT(self, view): """Ensure view.RAW_CONTENT returns content for PUT request with non-form content.""" @@ -44,36 +44,36 @@ class TestContentParsing(TestCase): content_type = 'text/plain' view.parsers = (PlainTextParser,) view.request = self.req.post('/', content, content_type=content_type) - self.assertEqual(view.RAW_CONTENT, content) + self.assertEqual(view.DATA, content) def test_standard_behaviour_determines_no_content_GET(self): - """Ensure view.RAW_CONTENT returns None for GET request with no content.""" + """Ensure view.DATA returns None for GET request with no content.""" self.ensure_determines_no_content_GET(RequestMixin()) def test_standard_behaviour_determines_form_content_POST(self): - """Ensure view.RAW_CONTENT returns content for POST request with form content.""" + """Ensure view.DATA returns content for POST request with form content.""" self.ensure_determines_form_content_POST(RequestMixin()) def test_standard_behaviour_determines_non_form_content_POST(self): - """Ensure view.RAW_CONTENT returns content for POST request with non-form content.""" + """Ensure view.DATA returns content for POST request with non-form content.""" self.ensure_determines_non_form_content_POST(RequestMixin()) def test_standard_behaviour_determines_form_content_PUT(self): - """Ensure view.RAW_CONTENT returns content for PUT request with form content.""" + """Ensure view.DATA returns content for PUT request with form content.""" self.ensure_determines_form_content_PUT(RequestMixin()) def test_standard_behaviour_determines_non_form_content_PUT(self): - """Ensure view.RAW_CONTENT returns content for PUT request with non-form content.""" + """Ensure view.DATA returns content for PUT request with non-form content.""" self.ensure_determines_non_form_content_PUT(RequestMixin()) def test_overloaded_behaviour_allows_content_tunnelling(self): - """Ensure request.RAW_CONTENT returns content for overloaded POST request""" + """Ensure request.DATA returns content for overloaded POST request""" content = 'qwerty' content_type = 'text/plain' view = RequestMixin() - form_data = {view.CONTENT_PARAM: content, - view.CONTENTTYPE_PARAM: content_type} + form_data = {view._CONTENT_PARAM: content, + view._CONTENTTYPE_PARAM: content_type} view.request = self.req.post('/', form_data) view.parsers = (PlainTextParser,) - view.perform_form_overloading() - self.assertEqual(view.RAW_CONTENT, content) + view._perform_form_overloading() + self.assertEqual(view.DATA, content) diff --git a/djangorestframework/tests/files.py b/djangorestframework/tests/files.py index f0321cb32..fc82fd83f 100644 --- a/djangorestframework/tests/files.py +++ b/djangorestframework/tests/files.py @@ -2,6 +2,7 @@ from django.test import TestCase from django import forms from djangorestframework.compat import RequestFactory from djangorestframework.views import BaseView +from djangorestframework.resource import FormResource import StringIO class UploadFilesTests(TestCase): @@ -15,9 +16,12 @@ class UploadFilesTests(TestCase): class FileForm(forms.Form): file = forms.FileField + class MockResource(FormResource): + form = FileForm + class MockView(BaseView): permissions = () - form = FileForm + resource = MockResource def post(self, request, *args, **kwargs): return {'FILE_NAME': self.CONTENT['file'].name, diff --git a/djangorestframework/tests/methods.py b/djangorestframework/tests/methods.py index 0e74dc948..961d518b6 100644 --- a/djangorestframework/tests/methods.py +++ b/djangorestframework/tests/methods.py @@ -22,6 +22,6 @@ class TestMethodOverloading(TestCase): def test_overloaded_POST_behaviour_determines_overloaded_method(self): """POST requests can be overloaded to another method by setting a reserved form field""" view = RequestMixin() - view.request = self.req.post('/', {view.METHOD_PARAM: 'DELETE'}) - view.perform_form_overloading() + view.request = self.req.post('/', {view._METHOD_PARAM: 'DELETE'}) + view._perform_form_overloading() self.assertEqual(view.method, 'DELETE') diff --git a/djangorestframework/tests/parsers.py b/djangorestframework/tests/parsers.py index 88aad8805..2720f4c77 100644 --- a/djangorestframework/tests/parsers.py +++ b/djangorestframework/tests/parsers.py @@ -24,7 +24,8 @@ Here is some example data, which would eventually be sent along with a post requ Default behaviour for :class:`parsers.FormParser`, is to return a single value for each parameter : - >>> FormParser(some_view).parse(StringIO(inpt)) == {'key1': 'bla1', 'key2': 'blo1'} + >>> (data, files) = FormParser(some_view).parse(StringIO(inpt)) + >>> data == {'key1': 'bla1', 'key2': 'blo1'} True However, you can customize this behaviour by subclassing :class:`parsers.FormParser`, and overriding :meth:`parsers.FormParser.is_a_list` : @@ -36,7 +37,8 @@ However, you can customize this behaviour by subclassing :class:`parsers.FormPar This new parser only flattens the lists of parameters that contain a single value. - >>> MyFormParser(some_view).parse(StringIO(inpt)) == {'key1': 'bla1', 'key2': ['blo1', 'blo2']} + >>> (data, files) = MyFormParser(some_view).parse(StringIO(inpt)) + >>> data == {'key1': 'bla1', 'key2': ['blo1', 'blo2']} True .. note:: The same functionality is available for :class:`parsers.MultiPartParser`. @@ -61,7 +63,8 @@ The browsers usually strip the parameter completely. A hack to avoid this, and t :class:`parsers.FormParser` strips the values ``_empty`` from all the lists. - >>> MyFormParser(some_view).parse(StringIO(inpt)) == {'key1': 'blo1'} + >>> (data, files) = MyFormParser(some_view).parse(StringIO(inpt)) + >>> data == {'key1': 'blo1'} True Oh ... but wait a second, the parameter ``key2`` isn't even supposed to be a list, so the parser just stripped it. @@ -71,7 +74,8 @@ Oh ... but wait a second, the parameter ``key2`` isn't even supposed to be a lis ... def is_a_list(self, key, val_list): ... return key == 'key2' ... - >>> MyFormParser(some_view).parse(StringIO(inpt)) == {'key1': 'blo1', 'key2': []} + >>> (data, files) = MyFormParser(some_view).parse(StringIO(inpt)) + >>> data == {'key1': 'blo1', 'key2': []} True Better like that. Note that you can configure something else than ``_empty`` for the empty value by setting :attr:`parsers.FormParser.EMPTY_VALUE`. @@ -123,7 +127,7 @@ class TestMultiPartParser(TestCase): post_req = RequestFactory().post('/', self.body, content_type=self.content_type) view = BaseView() view.request = post_req - parsed = MultiPartParser(view).parse(StringIO(self.body)) - self.assertEqual(parsed['key1'], 'val1') - self.assertEqual(parsed.FILES['file1'].read(), 'blablabla') + (data, files) = MultiPartParser(view).parse(StringIO(self.body)) + self.assertEqual(data['key1'], 'val1') + self.assertEqual(files['file1'].read(), 'blablabla') diff --git a/djangorestframework/views.py b/djangorestframework/views.py index 3ce4e1d69..3abf101c0 100644 --- a/djangorestframework/views.py +++ b/djangorestframework/views.py @@ -17,7 +17,7 @@ __all__ = ( -class BaseView(RequestMixin, ResponseMixin, AuthMixin, View): +class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View): """Handles incoming requests and maps them to REST operations. Performs request deserialization, response serialization, authentication and input validation.""" @@ -46,9 +46,6 @@ class BaseView(RequestMixin, ResponseMixin, AuthMixin, View): # List of all permissions that must be checked. permissions = ( permissions.FullAnonAccess, ) - # 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 renderers. @@ -60,22 +57,13 @@ class BaseView(RequestMixin, ResponseMixin, AuthMixin, View): return [method.upper() for method in self.http_method_names if hasattr(self, method)] def http_method_not_allowed(self, request, *args, **kwargs): - """Return an HTTP 405 error if an operation is called which does not have a handler method.""" + """ + Return an HTTP 405 error if an operation is called which does not have a handler method. + """ raise ErrorResponse(status.HTTP_405_METHOD_NOT_ALLOWED, {'detail': 'Method \'%s\' not allowed on this resource.' % self.method}) - def cleanup_response(self, data): - """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. - - TODO: This is going to be removed. I think that the 'fields' behaviour is going to move into - the RendererMixin and Renderer classes.""" - return data - - # Note: session based authentication is explicitly CSRF validated, # all other authentication is CSRF exempt. @csrf_exempt @@ -92,7 +80,7 @@ class BaseView(RequestMixin, ResponseMixin, AuthMixin, View): try: # If using a form POST with '_method'/'_content'/'_content_type' overrides, then alter # self.method, self.content_type, self.RAW_CONTENT & self.CONTENT appropriately. - self.perform_form_overloading() + self._perform_form_overloading() # Authenticate and check request is has the relevant permissions self._check_permissions() @@ -114,13 +102,14 @@ class BaseView(RequestMixin, ResponseMixin, AuthMixin, View): response = Response(status.HTTP_204_NO_CONTENT) # Pre-serialize filtering (eg filter complex objects into natively serializable types) - response.cleaned_content = self.resource.object_to_serializable(response.raw_content) + response.cleaned_content = self.object_to_data(response.raw_content) except ErrorResponse, exc: response = exc.response except: import traceback traceback.print_exc() + raise # Always add these headers. # diff --git a/examples/sandbox/views.py b/examples/sandbox/views.py index 04e4da412..78b722caf 100644 --- a/examples/sandbox/views.py +++ b/examples/sandbox/views.py @@ -1,10 +1,10 @@ """The root view for the examples provided with Django REST framework""" from django.core.urlresolvers import reverse -from djangorestframework.resource import Resource +from djangorestframework.views import BaseView -class Sandbox(Resource): +class Sandbox(BaseView): """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. From b5b231a874c7d8d54b1d3849cb95337f15bac9c6 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 12 May 2011 15:11:14 +0100 Subject: [PATCH 39/70] yet more API cleanup --- djangorestframework/mixins.py | 124 +++++++++----------- djangorestframework/renderers.py | 79 +++++++------ djangorestframework/templates/renderer.html | 18 +-- djangorestframework/tests/accept.py | 4 +- djangorestframework/tests/files.py | 2 +- djangorestframework/tests/validators.py | 2 +- djangorestframework/views.py | 14 +-- 7 files changed, 119 insertions(+), 124 deletions(-) diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index d1c83c177..4f88bde49 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -48,23 +48,18 @@ class RequestMixin(object): parsers = () - def _get_method(self): + @property + def method(self): """ - Returns the HTTP method for the current view. + Returns the HTTP method. """ if not hasattr(self, '_method'): self._method = self.request.method return self._method - def _set_method(self, method): - """ - Set the method for the current view. - """ - self._method = method - - - def _get_content_type(self): + @property + def content_type(self): """ Returns the content type header. """ @@ -73,11 +68,32 @@ class RequestMixin(object): return self._content_type - def _set_content_type(self, content_type): + @property + def DATA(self): """ - Set the content type header. + Returns the request data. """ - self._content_type = content_type + if not hasattr(self, '_data'): + self._load_data_and_files() + return self._data + + + @property + def FILES(self): + """ + Returns the request files. + """ + if not hasattr(self, '_files'): + self._load_data_and_files() + return self._files + + + def _load_data_and_files(self): + """ + Parse the request content into self.DATA and self.FILES. + """ + stream = self._get_stream() + (self._data, self._files) = self._parse(stream, self.content_type) def _get_stream(self): @@ -134,27 +150,6 @@ class RequestMixin(object): return self._stream - def _set_stream(self, stream): - """ - Set the stream representing the request body. - """ - self._stream = stream - - - def _load_data_and_files(self): - (self._data, self._files) = self._parse(self.stream, self.content_type) - - def _get_data(self): - if not hasattr(self, '_data'): - self._load_data_and_files() - return self._data - - def _get_files(self): - if not hasattr(self, '_files'): - self._load_data_and_files() - return self._files - - # TODO: Modify this so that it happens implictly, rather than being called explicitly # ie accessing any of .DATA, .FILES, .content_type, .method will force # form overloading. @@ -164,7 +159,10 @@ class RequestMixin(object): If it is then alter self.method, self.content_type, self.CONTENT to reflect that rather than simply delegating them to the original request. """ - if not self._USE_FORM_OVERLOADING or self.method != 'POST' or not is_form_media_type(self.content_type): + + # We only need to use form overloading on form POST requests + content_type = self.request.META.get('HTTP_CONTENT_TYPE', self.request.META.get('CONTENT_TYPE', '')) + if not self._USE_FORM_OVERLOADING or self.request.method != 'POST' or not not is_form_media_type(content_type): return # Temporarily switch to using the form parsers, then parse the content @@ -175,7 +173,7 @@ class RequestMixin(object): # Method overloading - change the method and remove the param from the content if self._METHOD_PARAM in content: - self.method = content[self._METHOD_PARAM].upper() + self._method = content[self._METHOD_PARAM].upper() del self._data[self._METHOD_PARAM] # Content overloading - rewind the stream and modify the content type @@ -207,28 +205,21 @@ class RequestMixin(object): @property - def parsed_media_types(self): + def _parsed_media_types(self): """ - Return an list of all the media types that this view can parse. + Return a list of all the media types that this view can parse. """ return [parser.media_type for parser in self.parsers] @property - def default_parser(self): + def _default_parser(self): """ - Return the view's most preferred parser. - (This has no behavioral effect, but is may be used by documenting renderers) + Return the view's default parser. """ return self.parsers[0] - method = property(_get_method, _set_method) - content_type = property(_get_content_type, _set_content_type) - stream = property(_get_stream, _set_stream) - DATA = property(_get_data) - FILES = property(_get_files) - ########## ResponseMixin ########## @@ -240,8 +231,9 @@ class ResponseMixin(object): Also supports overriding the content type by specifying an _accept= parameter in the URL. Ignores Accept headers from Internet Explorer user agents and uses a sensible browser Accept header instead. """ - ACCEPT_QUERY_PARAM = '_accept' # Allow override of Accept header in URL query params - REWRITE_IE_ACCEPT_HEADER = True + + _ACCEPT_QUERY_PARAM = '_accept' # Allow override of Accept header in URL query params + _IGNORE_IE_ACCEPT_HEADER = True renderers = () @@ -256,7 +248,7 @@ class ResponseMixin(object): try: renderer = self._determine_renderer(self.request) except ErrorResponse, exc: - renderer = self.default_renderer + renderer = self._default_renderer response = exc.response # Serialize the response content @@ -287,10 +279,10 @@ class ResponseMixin(object): 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): + 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 + accept_list = [request.GET.get(self._ACCEPT_QUERY_PARAM)] + elif (self._IGNORE_IE_ACCEPT_HEADER and request.META.has_key('HTTP_USER_AGENT') and MSIE_USER_AGENT_REGEX.match(request.META['HTTP_USER_AGENT'])): accept_list = ['text/html', '*/*'] @@ -299,7 +291,7 @@ class ResponseMixin(object): accept_list = request.META["HTTP_ACCEPT"].split(',') else: # No accept header specified - return self.default_renderer + return self._default_renderer # Parse the accept header into a dict of {qvalue: set of media types} # We ignore mietype parameters @@ -340,25 +332,24 @@ class ResponseMixin(object): # Return default if '*/*' in accept_set: - return self.default_renderer + return self._default_renderer raise ErrorResponse(status.HTTP_406_NOT_ACCEPTABLE, {'detail': 'Could not satisfy the client\'s Accept header', - 'available_types': self.rendered_media_types}) + 'available_types': self._rendered_media_types}) @property - def rendered_media_types(self): + def _rendered_media_types(self): """ - Return an list of all the media types that this resource can render. + Return an list of all the media types that this view can render. """ return [renderer.media_type for renderer in self.renderers] @property - def default_renderer(self): + def _default_renderer(self): """ - Return the resource's most preferred renderer. - (This renderer is used if the client does not send and Accept: header, or sends Accept: */*) + Return the view's default renderer. """ return self.renderers[0] @@ -367,8 +358,7 @@ class ResponseMixin(object): class AuthMixin(object): """ - Simple mixin class to provide authentication and permission checking, - by adding a set of authentication and permission classes on a ``View``. + Simple mixin class to add authentication and permission checking to a ``View`` class. """ authentication = () permissions = () @@ -404,16 +394,16 @@ class AuthMixin(object): ########## Resource Mixin ########## -class ResourceMixin(object): +class ResourceMixin(object): @property def CONTENT(self): if not hasattr(self, '_content'): - self._content = self._get_content(self.DATA, self.FILES) + self._content = self._get_content() return self._content - def _get_content(self, data, files): + def _get_content(self): resource = self.resource(self) - return resource.validate(data, files) + return resource.validate(self.DATA, self.FILES) def get_bound_form(self, content=None): resource = self.resource(self) diff --git a/djangorestframework/renderers.py b/djangorestframework/renderers.py index 0aa30f707..e8763f349 100644 --- a/djangorestframework/renderers.py +++ b/djangorestframework/renderers.py @@ -52,7 +52,7 @@ class BaseRenderer(object): should render the output. EG: 'application/json; indent=4' - By default render simply returns the ouput as-is. + By default render simply returns the output as-is. Override this method to provide for other behavior. """ if obj is None: @@ -61,6 +61,41 @@ class BaseRenderer(object): return str(obj) +class JSONRenderer(BaseRenderer): + """ + Renderer which serializes to JSON + """ + media_type = 'application/json' + + def render(self, obj=None, media_type=None): + if obj is None: + return '' + + # If the media type looks like 'application/json; indent=4', then + # pretty print the result. + indent = get_media_type_params(media_type).get('indent', None) + sort_keys = False + try: + indent = max(min(int(indent), 8), 0) + sort_keys = True + except (ValueError, TypeError): + indent = None + + return json.dumps(obj, indent=indent, sort_keys=sort_keys) + + +class XMLRenderer(BaseRenderer): + """ + Renderer which serializes to XML. + """ + media_type = 'application/xml' + + def render(self, obj=None, media_type=None): + if obj is None: + return '' + return dict2xml(obj) + + class TemplateRenderer(BaseRenderer): """ A Base class provided for convenience. @@ -161,8 +196,8 @@ class DocumentingTemplateRenderer(BaseRenderer): Add the fields dynamically.""" super(GenericContentForm, self).__init__() - contenttype_choices = [(media_type, media_type) for media_type in view.parsed_media_types] - initial_contenttype = view.default_parser.media_type + contenttype_choices = [(media_type, media_type) for media_type in view._parsed_media_types] + initial_contenttype = view._default_parser.media_type self.fields[view._CONTENTTYPE_PARAM] = forms.ChoiceField(label='Content Type', choices=contenttype_choices, @@ -204,16 +239,19 @@ class DocumentingTemplateRenderer(BaseRenderer): template = loader.get_template(self.template) context = RequestContext(self.view.request, { 'content': content, - 'resource': self.view, # TODO: rename to view + 'view': self.view, 'request': self.view.request, # TODO: remove 'response': self.view.response, 'description': description, 'name': name, 'markeddown': markeddown, 'breadcrumblist': breadcrumb_list, + 'available_media_types': self.view._rendered_media_types, 'form': form_instance, 'login_url': login_url, 'logout_url': logout_url, + 'ACCEPT_PARAM': self.view._ACCEPT_QUERY_PARAM, + 'METHOD_PARAM': self.view._METHOD_PARAM, 'ADMIN_MEDIA_PREFIX': settings.ADMIN_MEDIA_PREFIX }) @@ -228,39 +266,6 @@ class DocumentingTemplateRenderer(BaseRenderer): return ret -class JSONRenderer(BaseRenderer): - """ - Renderer which serializes to JSON - """ - media_type = 'application/json' - - def render(self, obj=None, media_type=None): - if obj is None: - return '' - - indent = get_media_type_params(media_type).get('indent', None) - if indent is not None: - try: - indent = int(indent) - except ValueError: - indent = None - - sort_keys = indent and True or False - return json.dumps(obj, indent=indent, sort_keys=sort_keys) - - -class XMLRenderer(BaseRenderer): - """ - Renderer which serializes to XML. - """ - media_type = 'application/xml' - - def render(self, obj=None, media_type=None): - if obj is None: - return '' - return dict2xml(obj) - - class DocumentingHTMLRenderer(DocumentingTemplateRenderer): """ Renderer which provides a browsable HTML interface for an API. diff --git a/djangorestframework/templates/renderer.html b/djangorestframework/templates/renderer.html index e213ecfa9..3010d7125 100644 --- a/djangorestframework/templates/renderer.html +++ b/djangorestframework/templates/renderer.html @@ -42,14 +42,14 @@ {% endfor %} {{ content|urlize_quoted_links }}{% endautoescape %}
- {% if 'GET' in resource.allowed_methods %} + {% if 'GET' in view.allowed_methods %}

GET {{ name }}

GET - {% for media_type in resource.rendered_media_types %} - {% with resource.ACCEPT_QUERY_PARAM|add:"="|add:media_type as param %} + {% for media_type in available_media_types %} + {% with ACCEPT_PARAM|add:"="|add:media_type as param %} [{{ media_type }}] {% endwith %} {% endfor %} @@ -63,8 +63,8 @@ *** (We could display only the POST form if method tunneling is disabled, but I think *** *** the user experience would be confusing, so we simply turn all forms off. *** {% endcomment %} - {% if resource.METHOD_PARAM and form %} - {% if 'POST' in resource.allowed_methods %} + {% if METHOD_PARAM and form %} + {% if 'POST' in view.allowed_methods %}

POST {{ name }}

@@ -85,11 +85,11 @@ {% endif %} - {% if 'PUT' in resource.allowed_methods %} + {% if 'PUT' in view.allowed_methods %}

PUT {{ name }}

- + {% csrf_token %} {{ form.non_field_errors }} {% for field in form %} @@ -107,12 +107,12 @@ {% endif %} - {% if 'DELETE' in resource.allowed_methods %} + {% if 'DELETE' in view.allowed_methods %}

DELETE {{ name }}

{% csrf_token %} - +
diff --git a/djangorestframework/tests/accept.py b/djangorestframework/tests/accept.py index c5a3f69e8..293a72847 100644 --- a/djangorestframework/tests/accept.py +++ b/djangorestframework/tests/accept.py @@ -40,9 +40,9 @@ class UserAgentMungingTest(TestCase): self.assertEqual(resp['Content-Type'], 'text/html') def test_dont_rewrite_msie_accept_header(self): - """Turn off REWRITE_IE_ACCEPT_HEADER, send MSIE user agent strings and ensure + """Turn off _IGNORE_IE_ACCEPT_HEADER, send MSIE user agent strings and ensure that we get a JSON response if we set a */* accept header.""" - view = self.MockView.as_view(REWRITE_IE_ACCEPT_HEADER=False) + view = self.MockView.as_view(_IGNORE_IE_ACCEPT_HEADER=False) for user_agent in (MSIE_9_USER_AGENT, MSIE_8_USER_AGENT, diff --git a/djangorestframework/tests/files.py b/djangorestframework/tests/files.py index fc82fd83f..afa59b4e8 100644 --- a/djangorestframework/tests/files.py +++ b/djangorestframework/tests/files.py @@ -2,7 +2,7 @@ from django.test import TestCase from django import forms from djangorestframework.compat import RequestFactory from djangorestframework.views import BaseView -from djangorestframework.resource import FormResource +from djangorestframework.resources import FormResource import StringIO class UploadFilesTests(TestCase): diff --git a/djangorestframework/tests/validators.py b/djangorestframework/tests/validators.py index 52a675d23..fb09c5baa 100644 --- a/djangorestframework/tests/validators.py +++ b/djangorestframework/tests/validators.py @@ -5,7 +5,7 @@ from djangorestframework.compat import RequestFactory from djangorestframework.validators import BaseValidator, FormValidator, ModelFormValidator from djangorestframework.response import ErrorResponse from djangorestframework.views import BaseView -from djangorestframework.resource import Resource +from djangorestframework.resources import Resource class TestValidatorMixinInterfaces(TestCase): diff --git a/djangorestframework/views.py b/djangorestframework/views.py index 3abf101c0..2a23c49a5 100644 --- a/djangorestframework/views.py +++ b/djangorestframework/views.py @@ -4,7 +4,7 @@ from django.views.decorators.csrf import csrf_exempt from djangorestframework.compat import View from djangorestframework.response import Response, ErrorResponse from djangorestframework.mixins import * -from djangorestframework import resource, renderers, parsers, authentication, permissions, validators, status +from djangorestframework import resources, renderers, parsers, authentication, permissions, status __all__ = ( @@ -22,7 +22,7 @@ class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View): Performs request deserialization, response serialization, authentication and input validation.""" # Use the base resource by default - resource = resource.Resource + resource = resources.Resource # List of renderers the resource can serialize the response with, ordered by preference. renderers = ( renderers.JSONRenderer, @@ -36,9 +36,6 @@ class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View): parsers.FormParser, parsers.MultiPartParser ) - # List of validators to validate, cleanup and normalize the request content - validators = ( validators.FormValidator, ) - # List of all authenticating methods to attempt. authentication = ( authentication.UserLoggedInAuthenticaton, authentication.BasicAuthenticaton ) @@ -54,6 +51,9 @@ class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View): @property def allowed_methods(self): + """ + Return the list of allowed HTTP methods, uppercased. + """ return [method.upper() for method in self.http_method_names if hasattr(self, method)] def http_method_not_allowed(self, request, *args, **kwargs): @@ -61,7 +61,7 @@ class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View): Return an HTTP 405 error if an operation is called which does not have a handler method. """ raise ErrorResponse(status.HTTP_405_METHOD_NOT_ALLOWED, - {'detail': 'Method \'%s\' not allowed on this resource.' % self.method}) + {'detail': 'Method \'%s\' not allowed on this resource.' % self.method}) # Note: session based authentication is explicitly CSRF validated, @@ -127,7 +127,7 @@ class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View): class ModelView(BaseView): """A RESTful view that maps to a model in the database.""" - validators = (validators.ModelFormValidator,) + resource = resources.ModelResource class InstanceModelView(ReadModelMixin, UpdateModelMixin, DeleteModelMixin, ModelView): """A view which provides default operations for read/update/delete against a model instance.""" From a31a68d6cba30c95251dad70bf4d18f83f782c67 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 12 May 2011 15:14:22 +0100 Subject: [PATCH 40/70] yet more API cleanup --- djangorestframework/mixins.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 4f88bde49..caaff7e0f 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -54,7 +54,7 @@ class RequestMixin(object): Returns the HTTP method. """ if not hasattr(self, '_method'): - self._method = self.request.method + self._load_metadata() return self._method @@ -64,7 +64,7 @@ class RequestMixin(object): Returns the content type header. """ if not hasattr(self, '_content_type'): - self._content_type = self.request.META.get('HTTP_CONTENT_TYPE', self.request.META.get('CONTENT_TYPE', '')) + self._load_metadata() return self._content_type @@ -95,6 +95,14 @@ class RequestMixin(object): stream = self._get_stream() (self._data, self._files) = self._parse(stream, self.content_type) + def _load_metadata(self): + """ + Set the method and content_type and then check if they've been overridden. + """ + self._method = self.request.method + self._content_type = self.request.META.get('HTTP_CONTENT_TYPE', self.request.META.get('CONTENT_TYPE', '')) + self._perform_form_overloading() + def _get_stream(self): """ @@ -161,8 +169,7 @@ class RequestMixin(object): """ # We only need to use form overloading on form POST requests - content_type = self.request.META.get('HTTP_CONTENT_TYPE', self.request.META.get('CONTENT_TYPE', '')) - if not self._USE_FORM_OVERLOADING or self.request.method != 'POST' or not not is_form_media_type(content_type): + if not self._USE_FORM_OVERLOADING or self._method != 'POST' or not not is_form_media_type(self._content_type): return # Temporarily switch to using the form parsers, then parse the content From 44c8b89c6051483677e72a6fc657b1e0457182d1 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 12 May 2011 16:03:14 +0100 Subject: [PATCH 41/70] _perform_form_overloading becomes transparent --- djangorestframework/mixins.py | 113 +++++++++------------------ djangorestframework/tests/content.py | 7 +- djangorestframework/tests/files.py | 4 +- djangorestframework/tests/methods.py | 1 - djangorestframework/views.py | 6 +- 5 files changed, 44 insertions(+), 87 deletions(-) diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index caaff7e0f..524e92682 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -54,7 +54,7 @@ class RequestMixin(object): Returns the HTTP method. """ if not hasattr(self, '_method'): - self._load_metadata() + self._load_method_and_content_type() return self._method @@ -64,7 +64,7 @@ class RequestMixin(object): Returns the content type header. """ if not hasattr(self, '_content_type'): - self._load_metadata() + self._load_method_and_content_type() return self._content_type @@ -92,12 +92,16 @@ class RequestMixin(object): """ Parse the request content into self.DATA and self.FILES. """ - stream = self._get_stream() - (self._data, self._files) = self._parse(stream, self.content_type) + if not hasattr(self, '_content_type'): + self._load_method_and_content_type() - def _load_metadata(self): + if not hasattr(self, '_data'): + (self._data, self._files) = self._parse(self._get_stream(), self._content_type) + + + def _load_method_and_content_type(self): """ - Set the method and content_type and then check if they've been overridden. + Set the method and content_type, and then check if they've been overridden. """ self._method = self.request.method self._content_type = self.request.META.get('HTTP_CONTENT_TYPE', self.request.META.get('CONTENT_TYPE', '')) @@ -108,86 +112,45 @@ class RequestMixin(object): """ Returns an object that may be used to stream the request content. """ - if not hasattr(self, '_stream'): - request = self.request + request = self.request - try: - content_length = int(request.META.get('CONTENT_LENGTH', request.META.get('HTTP_CONTENT_LENGTH'))) - except (ValueError, TypeError): - content_length = 0 + try: + content_length = int(request.META.get('CONTENT_LENGTH', request.META.get('HTTP_CONTENT_LENGTH'))) + except (ValueError, TypeError): + content_length = 0 - # TODO: Add 1.3's LimitedStream to compat and use that. - # Currently only supports parsing request body as a stream with 1.3 - if content_length == 0: - return None - elif hasattr(request, 'read'): - # UPDATE BASED ON COMMENT BELOW: - # - # Yup, this was a bug in Django - fixed and waiting check in - see ticket 15785. - # http://code.djangoproject.com/ticket/15785 - # - # COMMENT: - # - # It's not at all clear if this needs to be byte limited or not. - # Maybe I'm just being dumb but it looks to me like there's some issues - # with that in Django. - # - # Either: - # 1. It *can't* be treated as a limited byte stream, and you _do_ need to - # respect CONTENT_LENGTH, in which case that ought to be documented, - # and there probably ought to be a feature request for it to be - # treated as a limited byte stream. - # 2. It *can* be treated as a limited byte stream, in which case there's a - # minor bug in the test client, and potentially some redundant - # code in MultiPartParser. - # - # It's an issue because it affects if you can pass a request off to code that - # does something like: - # - # while stream.read(BUFFER_SIZE): - # [do stuff] - # - #try: - # content_length = int(request.META.get('CONTENT_LENGTH',0)) - #except (ValueError, TypeError): - # content_length = 0 - # self._stream = LimitedStream(request, content_length) - self._stream = request - else: - self._stream = StringIO(request.raw_post_data) - return self._stream + # TODO: Add 1.3's LimitedStream to compat and use that. + # Currently only supports parsing request body as a stream with 1.3 + if content_length == 0: + return None + elif hasattr(request, 'read'): + return request + return StringIO(request.raw_post_data) - # TODO: Modify this so that it happens implictly, rather than being called explicitly - # ie accessing any of .DATA, .FILES, .content_type, .method will force - # form overloading. def _perform_form_overloading(self): """ - Check the request to see if it is using form POST '_method'/'_content'/'_content_type' overrides. - If it is then alter self.method, self.content_type, self.CONTENT to reflect that rather than simply - delegating them to the original request. + If this is a form POST request, then we need to check if the method and content/content_type have been + overridden by setting them in hidden form fields or not. """ - # We only need to use form overloading on form POST requests - if not self._USE_FORM_OVERLOADING or self._method != 'POST' or not not is_form_media_type(self._content_type): + # We only need to use form overloading on form POST requests. + if not self._USE_FORM_OVERLOADING or self._method != 'POST' or not is_form_media_type(self._content_type): return + + # At this point we're committed to parsing the request as form data. + self._data = data = self.request.POST + self._files = self.request.FILES - # Temporarily switch to using the form parsers, then parse the content - parsers = self.parsers - self.parsers = (FormParser, MultiPartParser) - content = self.DATA - self.parsers = parsers + # Method overloading - change the method and remove the param from the content. + if self._METHOD_PARAM in data: + self._method = data[self._METHOD_PARAM].upper() - # Method overloading - change the method and remove the param from the content - if self._METHOD_PARAM in content: - self._method = content[self._METHOD_PARAM].upper() - del self._data[self._METHOD_PARAM] - - # Content overloading - rewind the stream and modify the content type - if self._CONTENT_PARAM in content and self._CONTENTTYPE_PARAM in content: - self._content_type = content[self._CONTENTTYPE_PARAM] - self._stream = StringIO(content[self._CONTENT_PARAM]) - del(self._data) + # Content overloading - modify the content type, and re-parse. + if self._CONTENT_PARAM in data and self._CONTENTTYPE_PARAM in data: + self._content_type = data[self._CONTENTTYPE_PARAM] + stream = StringIO(data[self._CONTENT_PARAM]) + (self._data, self._files) = self._parse(stream, self._content_type) def _parse(self, stream, content_type): diff --git a/djangorestframework/tests/content.py b/djangorestframework/tests/content.py index a99981fd1..fb7a2b14e 100644 --- a/djangorestframework/tests/content.py +++ b/djangorestframework/tests/content.py @@ -12,16 +12,16 @@ class TestContentParsing(TestCase): self.req = RequestFactory() def ensure_determines_no_content_GET(self, view): - """Ensure view.RAW_CONTENT returns None for GET request with no content.""" + """Ensure view.DATA returns None for GET request with no content.""" view.request = self.req.get('/') self.assertEqual(view.DATA, None) def ensure_determines_form_content_POST(self, view): - """Ensure view.RAW_CONTENT returns content for POST request with form content.""" + """Ensure view.DATA returns content for POST request with form content.""" form_data = {'qwerty': 'uiop'} view.parsers = (FormParser, MultiPartParser) view.request = self.req.post('/', data=form_data) - self.assertEqual(view.DATA, form_data) + self.assertEqual(view.DATA.items(), form_data.items()) def ensure_determines_non_form_content_POST(self, view): """Ensure view.RAW_CONTENT returns content for POST request with non-form content.""" @@ -75,5 +75,4 @@ class TestContentParsing(TestCase): view._CONTENTTYPE_PARAM: content_type} view.request = self.req.post('/', form_data) view.parsers = (PlainTextParser,) - view._perform_form_overloading() self.assertEqual(view.DATA, content) diff --git a/djangorestframework/tests/files.py b/djangorestframework/tests/files.py index afa59b4e8..3892422c1 100644 --- a/djangorestframework/tests/files.py +++ b/djangorestframework/tests/files.py @@ -24,8 +24,8 @@ class UploadFilesTests(TestCase): resource = MockResource def post(self, request, *args, **kwargs): - return {'FILE_NAME': self.CONTENT['file'].name, - 'FILE_CONTENT': self.CONTENT['file'].read()} + return {'FILE_NAME': self.CONTENT['file'][0].name, + 'FILE_CONTENT': self.CONTENT['file'][0].read()} file = StringIO.StringIO('stuff') file.name = 'stuff.txt' diff --git a/djangorestframework/tests/methods.py b/djangorestframework/tests/methods.py index 961d518b6..d8f0d9199 100644 --- a/djangorestframework/tests/methods.py +++ b/djangorestframework/tests/methods.py @@ -23,5 +23,4 @@ class TestMethodOverloading(TestCase): """POST requests can be overloaded to another method by setting a reserved form field""" view = RequestMixin() view.request = self.req.post('/', {view._METHOD_PARAM: 'DELETE'}) - view._perform_form_overloading() self.assertEqual(view.method, 'DELETE') diff --git a/djangorestframework/views.py b/djangorestframework/views.py index 2a23c49a5..211dafca4 100644 --- a/djangorestframework/views.py +++ b/djangorestframework/views.py @@ -77,11 +77,7 @@ class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View): prefix = '%s://%s' % (request.is_secure() and 'https' or 'http', request.get_host()) set_script_prefix(prefix) - try: - # If using a form POST with '_method'/'_content'/'_content_type' overrides, then alter - # self.method, self.content_type, self.RAW_CONTENT & self.CONTENT appropriately. - self._perform_form_overloading() - + try: # Authenticate and check request is has the relevant permissions self._check_permissions() From 8f6bcac7f3f156831343cc7fec79f624dcc2639f Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 13 May 2011 09:59:36 +0100 Subject: [PATCH 42/70] cleanup --- djangorestframework/mixins.py | 51 +++++++++- djangorestframework/parsers.py | 6 +- djangorestframework/renderers.py | 3 +- .../{resource.py => resources.py} | 20 +++- .../tests/{resource.py => resources.py} | 2 +- djangorestframework/views.py | 93 +++++++++---------- 6 files changed, 115 insertions(+), 60 deletions(-) rename djangorestframework/{resource.py => resources.py} (97%) rename djangorestframework/tests/{resource.py => resources.py} (94%) diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 524e92682..12f2d779a 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -46,12 +46,20 @@ class RequestMixin(object): _CONTENTTYPE_PARAM = '_content_type' _CONTENT_PARAM = '_content' + """ + The set of request parsers that the view can handle. + + Should be a tuple/list of classes as described in the ``parsers`` module. + """ parsers = () @property def method(self): """ Returns the HTTP method. + + This should be used instead of ``request.method``, as it allows the method + to be overridden by using a hidden form field on a form POST request. """ if not hasattr(self, '_method'): self._load_method_and_content_type() @@ -62,6 +70,10 @@ class RequestMixin(object): def content_type(self): """ Returns the content type header. + + This should be used instead of ``request.META.get('HTTP_CONTENT_TYPE')``, + as it allows the content type to be overridden by using a hidden form + field on a form POST request. """ if not hasattr(self, '_content_type'): self._load_method_and_content_type() @@ -71,7 +83,10 @@ class RequestMixin(object): @property def DATA(self): """ - Returns the request data. + Parses the request body and returns the data. + + Similar to ``request.POST``, except that it handles arbitrary parsers, + and also works on methods other than POST (eg PUT). """ if not hasattr(self, '_data'): self._load_data_and_files() @@ -81,7 +96,9 @@ class RequestMixin(object): @property def FILES(self): """ - Returns the request files. + Parses the request body and returns the files. + Similar to request.FILES, except that it handles arbitrary parsers, + and also works on methods other than POST (eg PUT). """ if not hasattr(self, '_files'): self._load_data_and_files() @@ -205,8 +222,14 @@ class ResponseMixin(object): _ACCEPT_QUERY_PARAM = '_accept' # Allow override of Accept header in URL query params _IGNORE_IE_ACCEPT_HEADER = True + """ + The set of response renderers that the view can handle. + + Should be a tuple/list of classes as described in the ``renderers`` module. + """ renderers = () - + + # TODO: wrap this behavior around dispatch(), ensuring it works # out of the box with existing Django classes that use render_to_response. def render(self, response): @@ -330,14 +353,33 @@ class AuthMixin(object): """ Simple mixin class to add authentication and permission checking to a ``View`` class. """ + + """ + The set of authentication types that this view can handle. + + + Should be a tuple/list of classes as described in the ``authentication`` module. + """ authentication = () + + """ + The set of permissions that will be enforced on this view. + + Should be a tuple/list of classes as described in the ``permissions`` module. + """ permissions = () + @property def user(self): + """ + Returns the user for the current request, as determined by the set of + authentication classes applied to the ``View``. + """ if not hasattr(self, '_user'): self._user = self._authenticate() return self._user + def _authenticate(self): """ @@ -351,6 +393,7 @@ class AuthMixin(object): return user return AnonymousUser() + # TODO: wrap this behavior around dispatch() def _check_permissions(self): """ @@ -359,7 +402,7 @@ class AuthMixin(object): user = self.user for permission_cls in self.permissions: permission = permission_cls(self) - permission.check_permission(user) + permission.check_permission(user) ########## Resource Mixin ########## diff --git a/djangorestframework/parsers.py b/djangorestframework/parsers.py index 9e1b971bb..4337098af 100644 --- a/djangorestframework/parsers.py +++ b/djangorestframework/parsers.py @@ -111,7 +111,8 @@ class PlainTextParser(BaseParser): class FormParser(BaseParser, DataFlatener): - """The default parser for form data. + """ + The default parser for form data. Return a dict containing a single value for each non-reserved parameter. In order to handle select multiple (and having possibly more than a single value for each parameter), @@ -122,7 +123,8 @@ class FormParser(BaseParser, DataFlatener): """The value of the parameter when the select multiple is empty. Browsers are usually stripping the select multiple that have no option selected from the parameters sent. A common hack to avoid this is to send the parameter with a value specifying that the list is empty. - This value will always be stripped before the data is returned.""" + This value will always be stripped before the data is returned. + """ EMPTY_VALUE = '_empty' RESERVED_FORM_PARAMS = ('csrfmiddlewaretoken',) diff --git a/djangorestframework/renderers.py b/djangorestframework/renderers.py index e8763f349..371b5ef0e 100644 --- a/djangorestframework/renderers.py +++ b/djangorestframework/renderers.py @@ -7,6 +7,7 @@ and providing forms and links depending on the allowed methods, renderers and pa """ from django import forms from django.conf import settings +from django.core.serializers.json import DateTimeAwareJSONEncoder from django.template import RequestContext, loader from django.utils import simplejson as json @@ -81,7 +82,7 @@ class JSONRenderer(BaseRenderer): except (ValueError, TypeError): indent = None - return json.dumps(obj, indent=indent, sort_keys=sort_keys) + return json.dumps(obj, cls=DateTimeAwareJSONEncoder, indent=indent, sort_keys=sort_keys) class XMLRenderer(BaseRenderer): diff --git a/djangorestframework/resource.py b/djangorestframework/resources.py similarity index 97% rename from djangorestframework/resource.py rename to djangorestframework/resources.py index 775d52889..f47b41d0e 100644 --- a/djangorestframework/resource.py +++ b/djangorestframework/resources.py @@ -16,9 +16,14 @@ def _model_to_dict(instance, fields=None, exclude=None): """ opts = instance._meta data = {} + + #print [rel.name for rel in opts.get_all_related_objects()] + #related = [rel.get_accessor_name() for rel in opts.get_all_related_objects()] + #print [getattr(instance, rel) for rel in related] + for f in opts.fields + opts.many_to_many: - if not f.editable: - continue + #if not f.editable: + # continue if fields and not f.name in fields: continue if exclude and f.name in exclude: @@ -27,6 +32,15 @@ def _model_to_dict(instance, fields=None, exclude=None): data[f.name] = getattr(instance, f.name) else: data[f.name] = f.value_from_object(instance) + + #print fields - (opts.fields + opts.many_to_many) + #for related in [rel.get_accessor_name() for rel in opts.get_all_related_objects()]: + # if fields and not related in fields: + # continue + # if exclude and related in exclude: + # continue + # data[related] = getattr(instance, related) + return data @@ -127,6 +141,8 @@ class Resource(BaseResource): A (horrible) munging of Piston's pre-serialization. Returns a dict. """ + return _object_to_data(obj) + def _any(thing, fields=()): """ Dispatch, all types are routed through here. diff --git a/djangorestframework/tests/resource.py b/djangorestframework/tests/resources.py similarity index 94% rename from djangorestframework/tests/resource.py rename to djangorestframework/tests/resources.py index 0ed41951b..6aa569d34 100644 --- a/djangorestframework/tests/resource.py +++ b/djangorestframework/tests/resources.py @@ -1,6 +1,6 @@ """Tests for the resource module""" from django.test import TestCase -from djangorestframework.resource import _object_to_data +from djangorestframework.resources import _object_to_data import datetime import decimal diff --git a/djangorestframework/views.py b/djangorestframework/views.py index 211dafca4..315c25a93 100644 --- a/djangorestframework/views.py +++ b/djangorestframework/views.py @@ -56,9 +56,10 @@ class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View): """ return [method.upper() for method in self.http_method_names if hasattr(self, method)] + def http_method_not_allowed(self, request, *args, **kwargs): """ - Return an HTTP 405 error if an operation is called which does not have a handler method. + Return an HTTP 405 error if an operation is called which does not have a handler method. """ raise ErrorResponse(status.HTTP_405_METHOD_NOT_ALLOWED, {'detail': 'Method \'%s\' not allowed on this resource.' % self.method}) @@ -68,56 +69,48 @@ class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View): # all other authentication is CSRF exempt. @csrf_exempt def dispatch(self, request, *args, **kwargs): - try: - self.request = request - self.args = args - self.kwargs = kwargs + self.request = request + self.args = args + self.kwargs = kwargs + + # 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) + + try: + # Authenticate and check request is has the relevant permissions + self._check_permissions() + + # Get the appropriate handler method + if self.method.lower() in self.http_method_names: + handler = getattr(self, self.method.lower(), self.http_method_not_allowed) + else: + handler = self.http_method_not_allowed + + response_obj = handler(request, *args, **kwargs) + + # Allow return value to be either Response, or an object, or None + if isinstance(response_obj, Response): + response = response_obj + elif response_obj is not None: + response = Response(status.HTTP_200_OK, response_obj) + else: + response = Response(status.HTTP_204_NO_CONTENT) + + # Pre-serialize filtering (eg filter complex objects into natively serializable types) + response.cleaned_content = self.object_to_data(response.raw_content) - # 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) - - try: - # Authenticate and check request is has the relevant permissions - self._check_permissions() - - # Get the appropriate handler method - if self.method.lower() in self.http_method_names: - handler = getattr(self, self.method.lower(), self.http_method_not_allowed) - else: - handler = self.http_method_not_allowed - - response_obj = handler(request, *args, **kwargs) - - # Allow return value to be either Response, or an object, or None - if isinstance(response_obj, Response): - response = response_obj - elif response_obj is not None: - response = Response(status.HTTP_200_OK, response_obj) - else: - response = Response(status.HTTP_204_NO_CONTENT) - - # Pre-serialize filtering (eg filter complex objects into natively serializable types) - response.cleaned_content = self.object_to_data(response.raw_content) - - except ErrorResponse, exc: - response = exc.response - except: - import traceback - traceback.print_exc() - raise - - # Always add these headers. - # - # TODO - this isn't actually the correct way to set the vary header, - # also it's currently sub-obtimal for HTTP caching - need to sort that out. - response.headers['Allow'] = ', '.join(self.allowed_methods) - response.headers['Vary'] = 'Authenticate, Accept' - - return self.render(response) - except: - import traceback - traceback.print_exc() + except ErrorResponse, exc: + response = exc.response + + # Always add these headers. + # + # TODO - this isn't actually the correct way to set the vary header, + # also it's currently sub-obtimal for HTTP caching - need to sort that out. + response.headers['Allow'] = ', '.join(self.allowed_methods) + response.headers['Vary'] = 'Authenticate, Accept' + + return self.render(response) From 325e63a3a767bf4aedef7be616cc268a08537424 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 13 May 2011 17:19:12 +0100 Subject: [PATCH 43/70] Sorting out resources. Doing some crazy magic automatic url resolving stuff. Yum. --- djangorestframework/mixins.py | 65 +++- djangorestframework/resources.py | 466 +++++++----------------- djangorestframework/tests/resources.py | 2 +- djangorestframework/utils/mediatypes.py | 1 + djangorestframework/views.py | 14 +- 5 files changed, 200 insertions(+), 348 deletions(-) diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 12f2d779a..70ec677ea 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -25,6 +25,8 @@ __all__ = ( 'ResponseMixin', 'AuthMixin', 'ResourceMixin', + # + 'InstanceMixin', # Model behavior mixins 'ReadModelMixin', 'CreateModelMixin', @@ -137,7 +139,7 @@ class RequestMixin(object): content_length = 0 # TODO: Add 1.3's LimitedStream to compat and use that. - # Currently only supports parsing request body as a stream with 1.3 + # NOTE: Currently only supports parsing request body as a stream with 1.3 if content_length == 0: return None elif hasattr(request, 'read'): @@ -379,8 +381,8 @@ class AuthMixin(object): if not hasattr(self, '_user'): self._user = self._authenticate() return self._user - - + + def _authenticate(self): """ Attempt to authenticate the request using each authentication class in turn. @@ -405,26 +407,71 @@ class AuthMixin(object): permission.check_permission(user) +########## + +class InstanceMixin(object): + """ + Mixin class that is used to identify a view class as being the canonical identifier + for the resources it is mapped too. + """ + + @classmethod + def as_view(cls, **initkwargs): + """ + Store the callable object on the resource class that has been associated with this view. + """ + view = super(InstanceMixin, cls).as_view(**initkwargs) + if 'resource' in initkwargs: + # We do a little dance when we store the view callable... + # we need to store it wrapped in a 1-tuple, so that inspect will treat it + # as a function when we later look it up (rather than turning it into a method). + # This makes sure our URL reversing works ok. + initkwargs['resource'].view_callable = (view,) + return view + ########## Resource Mixin ########## class ResourceMixin(object): + """ + Provides request validation and response filtering behavior. + """ + + """ + Should be a class as described in the ``resources`` module. + + The ``resource`` is an object that maps a view onto it's representation on the server. + + It provides validation on the content of incoming requests, + and filters the object representation into a serializable object for the response. + """ + resource = None + @property def CONTENT(self): if not hasattr(self, '_content'): - self._content = self._get_content() + self._content = self.validate_request(self.DATA, self.FILES) return self._content - def _get_content(self): + def validate_request(self, data, files): + """ + Given the request data return the cleaned, validated content. + Typically raises a ErrorResponse with status code 400 (Bad Request) on failure. + """ resource = self.resource(self) - return resource.validate(self.DATA, self.FILES) + return resource.validate_request(data, files) + + def filter_response(self, obj): + """ + Given the response content, filter it into a serializable object. + """ + resource = self.resource(self) + return resource.filter_response(obj) def get_bound_form(self, content=None): resource = self.resource(self) return resource.get_bound_form(content) - def object_to_data(self, obj): - resource = self.resource(self) - return resource.object_to_data(obj) + ########## Model Mixins ########## diff --git a/djangorestframework/resources.py b/djangorestframework/resources.py index f47b41d0e..31b9b0141 100644 --- a/djangorestframework/resources.py +++ b/djangorestframework/resources.py @@ -1,3 +1,5 @@ +from django import forms +from django.core.urlresolvers import reverse, get_urlconf, get_resolver, NoReverseMatch from django.db import models from django.db.models.query import QuerySet from django.db.models.fields.related import RelatedField @@ -9,10 +11,15 @@ import re -def _model_to_dict(instance, fields=None, exclude=None): +def _model_to_dict(instance, resource=None): """ - This is a clone of Django's ``django.forms.model_to_dict`` except that it - doesn't coerce related objects into primary keys. + Given a model instance, return a ``dict`` representing the model. + + The implementation is similar to Django's ``django.forms.model_to_dict``, except: + + * It doesn't coerce related objects into primary keys. + * It doesn't drop ``editable=False`` fields. + * It also supports attribute or method fields on the instance or resource. """ opts = instance._meta data = {} @@ -20,10 +27,19 @@ def _model_to_dict(instance, fields=None, exclude=None): #print [rel.name for rel in opts.get_all_related_objects()] #related = [rel.get_accessor_name() for rel in opts.get_all_related_objects()] #print [getattr(instance, rel) for rel in related] + #if resource.fields: + # fields = resource.fields + #else: + # fields = set(opts.fields + opts.many_to_many) + + fields = resource.fields + include = resource.include + exclude = resource.exclude + extra_fields = fields and list(resource.fields) or [] + + # Model fields for f in opts.fields + opts.many_to_many: - #if not f.editable: - # continue if fields and not f.name in fields: continue if exclude and f.name in exclude: @@ -32,87 +48,84 @@ def _model_to_dict(instance, fields=None, exclude=None): data[f.name] = getattr(instance, f.name) else: data[f.name] = f.value_from_object(instance) - - #print fields - (opts.fields + opts.many_to_many) - #for related in [rel.get_accessor_name() for rel in opts.get_all_related_objects()]: - # if fields and not related in fields: - # continue - # if exclude and related in exclude: - # continue - # data[related] = getattr(instance, related) + + if extra_fields and f.name in extra_fields: + extra_fields.remove(f.name) + # Method fields + for fname in extra_fields: + if hasattr(resource, fname): + # check the resource first, to allow it to override fields + obj = getattr(resource, fname) + # if it's a method like foo(self, instance), then call it + if inspect.ismethod(obj) and len(inspect.getargspec(obj)[0]) == 2: + obj = obj(instance) + elif hasattr(instance, fname): + # now check the object instance + obj = getattr(instance, fname) + else: + continue + + # TODO: It would be nicer if this didn't recurse here. + # Let's keep _model_to_dict flat, and _object_to_data recursive. + data[fname] = _object_to_data(obj) + return data -def _object_to_data(obj): +def _object_to_data(obj, resource=None): """ Convert an object into a serializable representation. """ if isinstance(obj, dict): # dictionaries - return dict([ (key, _object_to_data(val)) for key, val in obj.iteritems() ]) + # TODO: apply same _model_to_dict logic fields/exclude here + return dict([ (key, _object_to_data(val)) for key, val in obj.iteritems() ]) if isinstance(obj, (tuple, list, set, QuerySet)): # basic iterables - return [_object_to_data(item) for item in obj] + return [_object_to_data(item, resource) for item in obj] if isinstance(obj, models.Manager): # Manager objects - return [_object_to_data(item) for item in obj.all()] + return [_object_to_data(item, resource) for item in obj.all()] if isinstance(obj, models.Model): # Model instances - return _object_to_data(_model_to_dict(obj)) + return _object_to_data(_model_to_dict(obj, resource)) if isinstance(obj, decimal.Decimal): # Decimals (force to string representation) return str(obj) if inspect.isfunction(obj) and not inspect.getargspec(obj)[0]: # function with no args - return _object_to_data(obj()) - if inspect.ismethod(obj) and len(inspect.getargspec(obj)[0]) == 1: - # method with only a 'self' args - return _object_to_data(obj()) + return _object_to_data(obj(), resource) + if inspect.ismethod(obj) and len(inspect.getargspec(obj)[0]) <= 1: + # bound method + return _object_to_data(obj(), resource) - # fallback return smart_unicode(obj, strings_only=True) -def _form_to_data(form): - """ - Returns a dict containing the data in a form instance. - - This code is pretty much a clone of the ``Form.as_p()`` ``Form.as_ul`` - and ``Form.as_table()`` methods, except that it returns data suitable - for arbitrary serialization, rather than rendering the result directly - into html. - """ - ret = {} - for name, field in form.fields.items(): - if not form.is_bound: - data = form.initial.get(name, field.initial) - if callable(data): - data = data() - else: - if isinstance(field, FileField) and form.data is None: - data = form.initial.get(name, field.initial) - else: - data = field.widget.value_from_datadict(form.data, form.files, name) - ret[name] = field.prepare_value(data) - return ret - - class BaseResource(object): - """Base class for all Resource classes, which simply defines the interface they provide.""" + """ + Base class for all Resource classes, which simply defines the interface they provide. + """ + fields = None + include = None + exclude = None def __init__(self, view): self.view = view - def validate(self, data, files): - """Given some content as input return some cleaned, validated content. + def validate_request(self, data, files): + """ + Given the request data return the cleaned, validated content. Typically raises a ErrorResponse with status code 400 (Bad Request) on failure. - - Must be overridden to be implemented.""" + """ return data - def object_to_data(self, obj): - return _object_to_data(obj) + def filter_response(self, obj): + """ + Given the response content, filter it into a serializable object. + """ + return _object_to_data(obj, self) class Resource(BaseResource): @@ -135,247 +148,18 @@ class Resource(BaseResource): # you should explicitly set the fields attribute on your class. fields = None - # TODO: Replace this with new Serializer code based on Forms API. - def object_to_data(self, obj): - """ - A (horrible) munging of Piston's pre-serialization. Returns a dict. - """ - - return _object_to_data(obj) - - def _any(thing, fields=()): - """ - Dispatch, all types are routed through here. - """ - ret = None - - if isinstance(thing, QuerySet): - ret = _qs(thing, fields=fields) - elif isinstance(thing, (tuple, list)): - ret = _list(thing) - elif isinstance(thing, dict): - ret = _dict(thing) - elif isinstance(thing, int): - ret = thing - elif isinstance(thing, bool): - ret = thing - elif isinstance(thing, type(None)): - ret = thing - elif isinstance(thing, decimal.Decimal): - ret = str(thing) - elif isinstance(thing, models.Model): - ret = _model(thing, fields=fields) - #elif isinstance(thing, HttpResponse): TRC - # raise HttpStatusCode(thing) - elif inspect.isfunction(thing): - if not inspect.getargspec(thing)[0]: - ret = _any(thing()) - elif hasattr(thing, '__rendertable__'): - f = thing.__rendertable__ - if inspect.ismethod(f) and len(inspect.getargspec(f)[0]) == 1: - ret = _any(f()) - else: - ret = unicode(thing) # TRC - - return ret - - def _fk(data, field): - """ - Foreign keys. - """ - return _any(getattr(data, field.name)) - - def _related(data, fields=()): - """ - Foreign keys. - """ - return [ _model(m, fields) for m in data.iterator() ] - - def _m2m(data, field, fields=()): - """ - Many to many (re-route to `_model`.) - """ - return [ _model(m, fields) for m in getattr(data, field.name).iterator() ] - - - def _method_fields(data, fields): - if not data: - return { } - - has = dir(data) - ret = dict() - - for field in fields: - if field in has: - ret[field] = getattr(data, field) - - return ret - - def _model(data, fields=()): - """ - Models. Will respect the `fields` and/or - `exclude` on the handler (see `typemapper`.) - """ - ret = { } - #handler = self.in_typemapper(type(data), self.anonymous) # TRC - handler = None # TRC - get_absolute_url = False - - if fields: - v = lambda f: getattr(data, f.attname) - - get_fields = set(fields) - if 'absolute_url' in get_fields: # MOVED (TRC) - get_absolute_url = True - - met_fields = _method_fields(handler, get_fields) # TRC - - for f in data._meta.local_fields: - if f.serialize and not any([ p in met_fields for p in [ f.attname, f.name ]]): - if not f.rel: - if f.attname in get_fields: - ret[f.attname] = _any(v(f)) - get_fields.remove(f.attname) - else: - if f.attname[:-3] in get_fields: - ret[f.name] = _fk(data, f) - get_fields.remove(f.name) - - for mf in data._meta.many_to_many: - if mf.serialize and mf.attname not in met_fields: - if mf.attname in get_fields: - ret[mf.name] = _m2m(data, mf) - get_fields.remove(mf.name) - - # try to get the remainder of fields - for maybe_field in get_fields: - - if isinstance(maybe_field, (list, tuple)): - model, fields = maybe_field - inst = getattr(data, model, None) - - if inst: - if hasattr(inst, 'all'): - ret[model] = _related(inst, fields) - elif callable(inst): - if len(inspect.getargspec(inst)[0]) == 1: - ret[model] = _any(inst(), fields) - else: - ret[model] = _model(inst, fields) - - elif maybe_field in met_fields: - # Overriding normal field which has a "resource method" - # so you can alter the contents of certain fields without - # using different names. - ret[maybe_field] = _any(met_fields[maybe_field](data)) - - else: - maybe = getattr(data, maybe_field, None) - if maybe: - if callable(maybe): - if len(inspect.getargspec(maybe)[0]) == 1: - ret[maybe_field] = _any(maybe()) - else: - ret[maybe_field] = _any(maybe) - else: - pass # TRC - #handler_f = getattr(handler or self.handler, maybe_field, None) - # - #if handler_f: - # ret[maybe_field] = _any(handler_f(data)) - - else: - # Add absolute_url if it exists - get_absolute_url = True - - # Add all the fields - for f in data._meta.fields: - if f.attname != 'id': - ret[f.attname] = _any(getattr(data, f.attname)) - - # Add all the propertiess - klass = data.__class__ - for attr in dir(klass): - if not attr.startswith('_') and not attr in ('pk','id') and isinstance(getattr(klass, attr, None), property): - #if attr.endswith('_url') or attr.endswith('_uri'): - # ret[attr] = self.make_absolute(_any(getattr(data, attr))) - #else: - ret[attr] = _any(getattr(data, attr)) - #fields = dir(data.__class__) + ret.keys() - #add_ons = [k for k in dir(data) if k not in fields and not k.startswith('_')] - #print add_ons - ###print dir(data.__class__) - #from django.db.models import Model - #model_fields = dir(Model) - - #for attr in dir(data): - ## #if attr.startswith('_'): - ## # continue - # if (attr in fields) and not (attr in model_fields) and not attr.startswith('_'): - # print attr, type(getattr(data, attr, None)), attr in fields, attr in model_fields - - #for k in add_ons: - # ret[k] = _any(getattr(data, k)) - - # TRC - # resouce uri - #if self.in_typemapper(type(data), self.anonymous): - # handler = self.in_typemapper(type(data), self.anonymous) - # if hasattr(handler, 'resource_uri'): - # url_id, fields = handler.resource_uri() - # ret['resource_uri'] = permalink( lambda: (url_id, - # (getattr(data, f) for f in fields) ) )() - - # TRC - #if hasattr(data, 'get_api_url') and 'resource_uri' not in ret: - # try: ret['resource_uri'] = data.get_api_url() - # except: pass - - # absolute uri - if hasattr(data, 'get_absolute_url') and get_absolute_url: - 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) - - return ret - - def _qs(data, fields=()): - """ - Querysets. - """ - return [ _any(v, fields) for v in data ] - - def _list(data): - """ - Lists. - """ - return [ _any(v) for v in data ] - - def _dict(data): - """ - Dictionaries. - """ - return dict([ (k, _any(v)) for k, v in data.iteritems() ]) - - # Kickstart the seralizin'. - return _any(obj, self.fields) - class FormResource(Resource): - """Validator class that uses forms for validation. + """ + Resource class that uses forms for validation. Also provides a get_bound_form() method which may be used by some renderers. - - The view class should provide `.form` attribute which specifies the form classmethod - to be used for validation. - + On calling validate() this validator may set a `.bound_form_instance` attribute on the - view, which may be used by some renderers.""" + view, which may be used by some renderers. + """ + form = None - - def validate(self, data, files): + def validate_request(self, data, files): """ Given some content as input return some cleaned, validated content. Raises a ErrorResponse with status code 400 (Bad Request) on failure. @@ -434,10 +218,12 @@ class FormResource(Resource): detail[u'errors'] = bound_form.non_field_errors() # Add standard field errors - field_errors = dict((key, map(unicode, val)) + field_errors = dict( + (key, map(unicode, val)) for (key, val) in bound_form.errors.iteritems() - if not key.startswith('__')) + if not key.startswith('__') + ) # Add any unknown field errors for key in unknown_fields: @@ -451,22 +237,24 @@ class FormResource(Resource): def get_bound_form(self, data=None, files=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.""" - form_cls = getattr(self, 'form', None) - - if not form_cls: + """ + 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 data is not None: - return form_cls(data, files) + return self.form(data, files) - return form_cls() + return self.form() class ModelResource(FormResource): - """Validator class that uses forms for validation and otherwise falls back to a model form if no form is set. - Also provides a get_bound_form() method which may be used by some renderers.""" + """ + Resource class that uses forms for validation and otherwise falls back to a model form if no form is set. + Also provides a get_bound_form() method which may be used by some renderers. + """ """The form class that should be used for validation, or None to use model form validation.""" form = None @@ -477,16 +265,16 @@ class ModelResource(FormResource): """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. - Setting the fields class attribute causes the exclude_fields class attribute to be disregarded.""" + Setting the fields class attribute causes the exclude 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') + exclude = ('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.) - def validate(self, data, files): + def validate_request(self, data, files): """ Given some content as input return some cleaned, validated content. Raises a ErrorResponse with status code 400 (Bad Request) on failure. @@ -503,66 +291,80 @@ class ModelResource(FormResource): return self._validate(data, files, allowed_extra_fields=self._property_fields_set) - def get_bound_form(self, data=None, files=None): + 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.""" - form_cls = getattr(self, 'form', None) - model_cls = getattr(self, 'model', None) - - if form_cls: + if self.form: # Use explict Form return super(ModelFormValidator, self).get_bound_form(data, files) - elif model_cls: + elif self.model: # Fall back to ModelForm which we create on the fly class OnTheFlyModelForm(forms.ModelForm): class Meta: - model = model_cls + model = self.model #fields = tuple(self._model_fields_set) # Instantiate the ModelForm as appropriate if content and isinstance(content, models.Model): # Bound to an existing model instance return OnTheFlyModelForm(instance=content) - elif not data is None: - return OnTheFlyModelForm(data, files) + elif content is not None: + return OnTheFlyModelForm(content) return OnTheFlyModelForm() # Both form and model not set? Okay bruv, whatevs... return None + def url(self, instance): + """ + Attempts to reverse resolve the url of the given model instance for this resource. + """ + + # dis does teh magicks... + urlconf = get_urlconf() + resolver = get_resolver(urlconf) + + possibilities = resolver.reverse_dict.getlist(self.view_callable[0]) + for tuple_item in possibilities: + possibility = tuple_item[0] + # pattern = tuple_item[1] + # Note: defaults = tuple_item[2] for django >= 1.3 + for result, params in possibility: + instance_attrs = dict([ (param, getattr(instance, param)) for param in params if hasattr(instance, param) ]) + try: + return reverse(self.view_callable[0], kwargs=instance_attrs) + except NoReverseMatch: + pass + raise NoReverseMatch + + @property def _model_fields_set(self): - """Return a set containing the names of validated fields on the model.""" - resource = self.view.resource - model = getattr(resource, 'model', None) - fields = getattr(resource, 'fields', self.fields) - exclude_fields = getattr(resource, 'exclude_fields', self.exclude_fields) - - model_fields = set(field.name for field in model._meta.fields) + """ + 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 fields: - return model_fields & set(as_tuple(fields)) + return model_fields & set(as_tuple(self.fields)) - return model_fields - set(as_tuple(exclude_fields)) + return model_fields - set(as_tuple(self.exclude)) @property def _property_fields_set(self): - """Returns a set containing the names of validated properties on the model.""" - resource = self.view.resource - model = getattr(resource, 'model', None) - fields = getattr(resource, 'fields', self.fields) - exclude_fields = getattr(resource, 'exclude_fields', self.exclude_fields) - - property_fields = set(attr for attr in dir(model) if - isinstance(getattr(model, attr, None), property) + """ + 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 fields: - return property_fields & set(as_tuple(fields)) + return property_fields & set(as_tuple(self.fields)) - return property_fields - set(as_tuple(exclude_fields)) + return property_fields - set(as_tuple(self.exclude)) diff --git a/djangorestframework/tests/resources.py b/djangorestframework/tests/resources.py index 6aa569d34..fd1226be3 100644 --- a/djangorestframework/tests/resources.py +++ b/djangorestframework/tests/resources.py @@ -11,7 +11,7 @@ class TestObjectToData(TestCase): def test_decimal(self): """Decimals need to be converted to a string representation.""" self.assertEquals(_object_to_data(decimal.Decimal('1.5')), '1.5') - + def test_function(self): """Functions with no arguments should be called.""" def foo(): diff --git a/djangorestframework/utils/mediatypes.py b/djangorestframework/utils/mediatypes.py index 62a5e6f36..190cdc2df 100644 --- a/djangorestframework/utils/mediatypes.py +++ b/djangorestframework/utils/mediatypes.py @@ -43,6 +43,7 @@ def add_media_type_param(media_type, key, val): media_type.params[key] = val return str(media_type) + def get_media_type_params(media_type): """ Return a dictionary of the parameters on the given media type. diff --git a/djangorestframework/views.py b/djangorestframework/views.py index 315c25a93..2e7e8418a 100644 --- a/djangorestframework/views.py +++ b/djangorestframework/views.py @@ -18,8 +18,10 @@ __all__ = ( class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View): - """Handles incoming requests and maps them to REST operations. - Performs request deserialization, response serialization, authentication and input validation.""" + """ + Handles incoming requests and maps them to REST operations. + Performs request deserialization, response serialization, authentication and input validation. + """ # Use the base resource by default resource = resources.Resource @@ -77,8 +79,8 @@ class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View): prefix = '%s://%s' % (request.is_secure() and 'https' or 'http', request.get_host()) set_script_prefix(prefix) - try: - # Authenticate and check request is has the relevant permissions + try: + # Authenticate and check request has the relevant permissions self._check_permissions() # Get the appropriate handler method @@ -98,7 +100,7 @@ class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View): response = Response(status.HTTP_204_NO_CONTENT) # Pre-serialize filtering (eg filter complex objects into natively serializable types) - response.cleaned_content = self.object_to_data(response.raw_content) + response.cleaned_content = self.filter_response(response.raw_content) except ErrorResponse, exc: response = exc.response @@ -118,7 +120,7 @@ class ModelView(BaseView): """A RESTful view that maps to a model in the database.""" resource = resources.ModelResource -class InstanceModelView(ReadModelMixin, UpdateModelMixin, DeleteModelMixin, ModelView): +class InstanceModelView(InstanceMixin, ReadModelMixin, UpdateModelMixin, DeleteModelMixin, ModelView): """A view which provides default operations for read/update/delete against a model instance.""" pass From c1b9af845375336b955202363f5de5ab9685448c Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 13 May 2011 17:39:52 +0100 Subject: [PATCH 44/70] Docs on resources --- djangorestframework/resources.py | 47 ++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/djangorestframework/resources.py b/djangorestframework/resources.py index 31b9b0141..b35457871 100644 --- a/djangorestframework/resources.py +++ b/djangorestframework/resources.py @@ -256,24 +256,41 @@ class ModelResource(FormResource): Also provides a get_bound_form() method which may be used by some renderers. """ - """The form class that should be used for validation, or None to use model form validation.""" + """ + The form class that should be used for request validation. + If set to ``None`` then the default model form validation will be used. + """ form = None - """The model class from which the model form should be constructed if no form is set.""" + """ + The model class which this resource maps to. + """ 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. - - Setting the fields class attribute causes the exclude class attribute to be disregarded.""" + """ + The list of fields to use on the output. + + May be any of: + + The name of a model field. + The name of an attribute on the model. + The name of an attribute on the resource. + The name of an method on the model, with a signature like ``func(self)``. + The name of an method on the resource, with a signature like ``func(self, instance)``. + """ fields = None - """The list of fields to exclude from the Model. This is only used if the fields class attribute is not set.""" + """ + The list of fields to exclude. This is only used if ``fields`` is not set. + """ exclude = ('id', 'pk') + """ + The list of fields to include. This is only used if ``fields`` is not set. + """ + include = ('url',) + - # 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_request(self, data, files): """ Given some content as input return some cleaned, validated content. @@ -292,10 +309,12 @@ class ModelResource(FormResource): def get_bound_form(self, content=None): - """Given some content return a Django form bound to that content. + """ + Given some content return a ``Form`` instance 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 the form class attribute has been explicitly set then that class will be used + to create the Form, otherwise the model will be used to create a ModelForm. + """ if self.form: # Use explict Form @@ -323,6 +342,10 @@ class ModelResource(FormResource): def url(self, instance): """ Attempts to reverse resolve the url of the given model instance for this resource. + + Requires a ``View`` with ``InstanceMixin`` to have been created for this resource. + + This method can be overridden if you need to set the resource url reversing explicitly. """ # dis does teh magicks... From 47645319c94fc536a2b3675edf8f2a02c1d11d12 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 13 May 2011 17:46:31 +0100 Subject: [PATCH 45/70] Ensure there is always a resource attribute set --- djangorestframework/mixins.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 70ec677ea..76c2208df 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -10,6 +10,7 @@ from django.http.multipartparser import LimitBytes from djangorestframework import status from djangorestframework.parsers import FormParser, MultiPartParser +from djangorestframework.resources import Resource from djangorestframework.response import Response, ErrorResponse from djangorestframework.utils import as_tuple, MSIE_USER_AGENT_REGEX from djangorestframework.utils.mediatypes import is_form_media_type @@ -444,7 +445,7 @@ class ResourceMixin(object): It provides validation on the content of incoming requests, and filters the object representation into a serializable object for the response. """ - resource = None + resource = Resource @property def CONTENT(self): From e92002ddde31fcc4ba3dee0f4a92a114c3c3a959 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 16 May 2011 09:14:01 +0100 Subject: [PATCH 46/70] nested resources now working --- djangorestframework/resources.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/djangorestframework/resources.py b/djangorestframework/resources.py index b35457871..19218b35c 100644 --- a/djangorestframework/resources.py +++ b/djangorestframework/resources.py @@ -10,6 +10,7 @@ import inspect import re +# TODO: _IgnoreFieldException def _model_to_dict(instance, resource=None): """ @@ -32,9 +33,9 @@ def _model_to_dict(instance, resource=None): #else: # fields = set(opts.fields + opts.many_to_many) - fields = resource.fields - include = resource.include - exclude = resource.exclude + fields = resource and resource.fields or () + include = resource and resource.include or () + exclude = resource and resource.exclude or () extra_fields = fields and list(resource.fields) or [] @@ -157,8 +158,13 @@ class FormResource(Resource): On calling validate() this validator may set a `.bound_form_instance` attribute on the view, which may be used by some renderers. """ + + """ + The form class that should be used for request validation. + """ form = None + def validate_request(self, data, files): """ Given some content as input return some cleaned, validated content. @@ -261,12 +267,12 @@ class ModelResource(FormResource): If set to ``None`` then the default model form validation will be used. """ form = None - + """ The model class which this resource maps to. """ model = None - + """ The list of fields to use on the output. @@ -286,7 +292,7 @@ class ModelResource(FormResource): exclude = ('id', 'pk') """ - The list of fields to include. This is only used if ``fields`` is not set. + The list of extra fields to include. This is only used if ``fields`` is not set. """ include = ('url',) @@ -342,7 +348,7 @@ class ModelResource(FormResource): def url(self, instance): """ Attempts to reverse resolve the url of the given model instance for this resource. - + Requires a ``View`` with ``InstanceMixin`` to have been created for this resource. This method can be overridden if you need to set the resource url reversing explicitly. From 1e04790d505a1174f9e3c4481288982f9e7fd6c0 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 16 May 2011 14:11:36 +0100 Subject: [PATCH 47/70] Fixing some of the last blocking issues --- djangorestframework/mixins.py | 19 ++++++- djangorestframework/renderers.py | 4 +- djangorestframework/resources.py | 75 ++++++++++++++++++++++--- examples/blogpost/models.py | 31 +--------- examples/blogpost/urls.py | 31 ++++++++-- examples/modelresourceexample/models.py | 14 ++--- examples/modelresourceexample/urls.py | 13 ++++- examples/modelresourceexample/views.py | 16 ------ examples/objectstore/views.py | 64 ++++++++++++--------- examples/pygments_api/views.py | 66 +++++++++++++++------- examples/resourceexample/views.py | 25 +++++++-- examples/sandbox/views.py | 4 +- 12 files changed, 234 insertions(+), 128 deletions(-) diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 76c2208df..278d4d4da 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -516,7 +516,7 @@ class CreateModelMixin(object): instance.save() headers = {} if hasattr(instance, 'get_absolute_url'): - headers['Location'] = instance.get_absolute_url() + headers['Location'] = self.resource(self).url(instance) return Response(status.HTTP_201_CREATED, instance, headers) @@ -569,10 +569,27 @@ class ListModelMixin(object): """ Behavior to list a set of model instances on GET requests """ + + # NB. Not obvious to me if it would be better to set this on the resource? + # + # Presumably it's more useful to have on the view, because that way you can + # have multiple views across different querysets mapping to the same resource. + # + # Perhaps it ought to be: + # + # 1) View.queryset + # 2) if None fall back to Resource.queryset + # 3) if None fall back to Resource.model.objects.all() + # + # Any feedback welcomed. queryset = None def get(self, request, *args, **kwargs): queryset = self.queryset if self.queryset else self.resource.model.objects.all() + ordering = getattr(self.resource, 'ordering', None) + if ordering: + args = as_tuple(ordering) + queryset = queryset.order_by(*args) return queryset.filter(**kwargs) diff --git a/djangorestframework/renderers.py b/djangorestframework/renderers.py index 371b5ef0e..112736d2f 100644 --- a/djangorestframework/renderers.py +++ b/djangorestframework/renderers.py @@ -251,8 +251,8 @@ class DocumentingTemplateRenderer(BaseRenderer): 'form': form_instance, 'login_url': login_url, 'logout_url': logout_url, - 'ACCEPT_PARAM': self.view._ACCEPT_QUERY_PARAM, - 'METHOD_PARAM': self.view._METHOD_PARAM, + 'ACCEPT_PARAM': getattr(self.view, '_ACCEPT_QUERY_PARAM', None), + 'METHOD_PARAM': getattr(self.view, '_METHOD_PARAM', None), 'ADMIN_MEDIA_PREFIX': settings.ADMIN_MEDIA_PREFIX }) diff --git a/djangorestframework/resources.py b/djangorestframework/resources.py index 19218b35c..8a9373cfc 100644 --- a/djangorestframework/resources.py +++ b/djangorestframework/resources.py @@ -5,6 +5,9 @@ from django.db.models.query import QuerySet from django.db.models.fields.related import RelatedField from django.utils.encoding import smart_unicode +from djangorestframework.response import ErrorResponse +from djangorestframework.utils import as_tuple + import decimal import inspect import re @@ -12,6 +15,10 @@ import re # TODO: _IgnoreFieldException +# Map model classes to resource classes +#_model_to_resource = {} + + def _model_to_dict(instance, resource=None): """ Given a model instance, return a ``dict`` representing the model. @@ -179,18 +186,31 @@ class FormResource(Resource): return self._validate(data, files) - def _validate(self, data, files, allowed_extra_fields=()): + def _validate(self, data, files, allowed_extra_fields=(), fake_data=None): """ 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. """ + + # We'd like nice error messages even if no content is supplied. + # Typically if an empty dict is given to a form Django will + # return .is_valid() == False, but .errors == {} + # + # To get around this case we revalidate with some fake data. + if fake_data: + data[fake_data] = '_fake_data' + allowed_extra_fields = allowed_extra_fields + ('_fake_data',) + bound_form = self.get_bound_form(data, files) if bound_form is None: return data self.view.bound_form_instance = bound_form + + data = data and data or {} + files = files and files or {} seen_fields_set = set(data.keys()) form_fields_set = set(bound_form.fields.keys()) @@ -198,6 +218,7 @@ class FormResource(Resource): # 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) + unknown_fields = unknown_fields - set(('csrfmiddlewaretoken',)) # Check using both regular validation, and our stricter no additional fields rule if bound_form.is_valid() and not unknown_fields: @@ -216,6 +237,13 @@ class FormResource(Resource): detail = {} if not bound_form.errors and not unknown_fields: + # is_valid() was False, but errors was empty. + # If we havn't already done so attempt revalidation with some fake data + # to force django to give us an errors dict. + if fake_data is None: + return self._validate(data, files, allowed_extra_fields, '_fake_data') + + # If we've already set fake_dict and we're still here, fallback gracefully. detail = {u'errors': [u'No content was supplied.']} else: @@ -256,12 +284,29 @@ class FormResource(Resource): return self.form() + +#class _RegisterModelResource(type): +# """ +# Auto register new ModelResource classes into ``_model_to_resource`` +# """ +# def __new__(cls, name, bases, dct): +# resource_cls = type.__new__(cls, name, bases, dct) +# model_cls = dct.get('model', None) +# if model_cls: +# _model_to_resource[model_cls] = resource_cls +# return resource_cls + + + class ModelResource(FormResource): """ Resource class that uses forms for validation and otherwise falls back to a model form if no form is set. Also provides a get_bound_form() method which may be used by some renderers. """ - + + # Auto-register new ModelResource classes into _model_to_resource + #__metaclass__ = _RegisterModelResource + """ The form class that should be used for request validation. If set to ``None`` then the default model form validation will be used. @@ -314,7 +359,7 @@ class ModelResource(FormResource): return self._validate(data, files, allowed_extra_fields=self._property_fields_set) - def get_bound_form(self, content=None): + def get_bound_form(self, data=None, files=None): """ Given some content return a ``Form`` instance bound to that content. @@ -334,11 +379,11 @@ class ModelResource(FormResource): #fields = tuple(self._model_fields_set) # Instantiate the ModelForm as appropriate - if content and isinstance(content, models.Model): + if data and isinstance(data, models.Model): # Bound to an existing model instance return OnTheFlyModelForm(instance=content) - elif content is not None: - return OnTheFlyModelForm(content) + elif data is not None: + return OnTheFlyModelForm(data, files) return OnTheFlyModelForm() # Both form and model not set? Okay bruv, whatevs... @@ -364,7 +409,19 @@ class ModelResource(FormResource): # pattern = tuple_item[1] # Note: defaults = tuple_item[2] for django >= 1.3 for result, params in possibility: - instance_attrs = dict([ (param, getattr(instance, param)) for param in params if hasattr(instance, param) ]) + + #instance_attrs = dict([ (param, getattr(instance, param)) for param in params if hasattr(instance, param) ]) + + instance_attrs = {} + for param in params: + if not hasattr(instance, param): + continue + attr = getattr(instance, param) + if isinstance(attr, models.Model): + instance_attrs[param] = attr.pk + else: + instance_attrs[param] = attr + try: return reverse(self.view_callable[0], kwargs=instance_attrs) except NoReverseMatch: @@ -393,7 +450,7 @@ class ModelResource(FormResource): isinstance(getattr(self.model, attr, None), property) and not attr.startswith('_')) - if fields: + if self.fields: return property_fields & set(as_tuple(self.fields)) - return property_fields - set(as_tuple(self.exclude)) + return property_fields.union(set(as_tuple(self.include))) - set(as_tuple(self.exclude)) diff --git a/examples/blogpost/models.py b/examples/blogpost/models.py index 3489c596b..c4925a15a 100644 --- a/examples/blogpost/models.py +++ b/examples/blogpost/models.py @@ -21,26 +21,10 @@ class BlogPost(models.Model): created = models.DateTimeField(auto_now_add=True) slug = models.SlugField(editable=False, default='') - class Meta: - ordering = ('created',) - - @models.permalink - def get_absolute_url(self): - 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 ('comments', (), {'blogpost': self.key}) - - def __unicode__(self): - return self.title - def save(self, *args, **kwargs): self.slug = slugify(self.title) super(self.__class__, self).save(*args, **kwargs) - for obj in self.__class__.objects.order_by('-pk')[MAX_POSTS:]: + for obj in self.__class__.objects.order_by('-created')[MAX_POSTS:]: obj.delete() @@ -51,16 +35,3 @@ class Comment(models.Model): rating = models.IntegerField(blank=True, null=True, choices=RATING_CHOICES, help_text='How did you rate this post?') created = models.DateTimeField(auto_now_add=True) - class Meta: - ordering = ('created',) - - @models.permalink - def get_absolute_url(self): - 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 ('blog-post', (), {'key': self.blogpost.key}) - diff --git a/examples/blogpost/urls.py b/examples/blogpost/urls.py index 1306b0d7d..130363b17 100644 --- a/examples/blogpost/urls.py +++ b/examples/blogpost/urls.py @@ -1,9 +1,28 @@ from django.conf.urls.defaults import patterns, url -from blogpost.views import BlogPosts, BlogPostInstance, Comments, CommentInstance +from django.core.urlresolvers import reverse + +from djangorestframework.views import ListOrCreateModelView, InstanceModelView +from djangorestframework.resources import ModelResource + +from blogpost.models import BlogPost, Comment + +class BlogPostResource(ModelResource): + model = BlogPost + fields = ('created', 'title', 'slug', 'content', 'url', 'comments') + ordering = ('-created',) + + def comments(self, instance): + return reverse('comments', kwargs={'blogpost': instance.key}) + +class CommentResource(ModelResource): + model = Comment + fields = ('username', 'comment', 'created', 'rating', 'url', 'blogpost') + ordering = ('-created',) + urlpatterns = patterns('', - url(r'^$', BlogPosts.as_view(), name='blog-posts'), - url(r'^(?P[^/]+)/$', BlogPostInstance.as_view(), name='blog-post'), - url(r'^(?P[^/]+)/comments/$', Comments.as_view(), name='comments'), - url(r'^(?P[^/]+)/comments/(?P[^/]+)/$', CommentInstance.as_view(), name='comment'), -) + url(r'^$', ListOrCreateModelView.as_view(resource=BlogPostResource), name='blog-posts-root'), + url(r'^(?P[^/]+)/$', InstanceModelView.as_view(resource=BlogPostResource)), + url(r'^(?P[^/]+)/comments/$', ListOrCreateModelView.as_view(resource=CommentResource), name='comments'), + url(r'^(?P[^/]+)/comments/(?P[^/]+)/$', InstanceModelView.as_view(resource=CommentResource)), +) \ No newline at end of file diff --git a/examples/modelresourceexample/models.py b/examples/modelresourceexample/models.py index 160475243..ff0179c88 100644 --- a/examples/modelresourceexample/models.py +++ b/examples/modelresourceexample/models.py @@ -7,17 +7,13 @@ class MyModel(models.Model): bar = models.IntegerField(help_text='Must be an integer.') baz = models.CharField(max_length=32, help_text='Free text. Max length 32 chars.') created = models.DateTimeField(auto_now_add=True) - - class Meta: - ordering = ('created',) def save(self, *args, **kwargs): - """For the purposes of the sandbox, limit the maximum number of stored models.""" + """ + For the purposes of the sandbox limit the maximum number of stored models. + """ super(MyModel, self).save(*args, **kwargs) while MyModel.objects.all().count() > MAX_INSTANCES: - MyModel.objects.all()[0].delete() - - @models.permalink - def get_absolute_url(self): - return ('my-model-resource', (self.pk,)) + MyModel.objects.all().order_by('-created')[0].delete() + diff --git a/examples/modelresourceexample/urls.py b/examples/modelresourceexample/urls.py index 53d950cd6..5860c807f 100644 --- a/examples/modelresourceexample/urls.py +++ b/examples/modelresourceexample/urls.py @@ -1,7 +1,14 @@ from django.conf.urls.defaults import patterns, url -from modelresourceexample.views import MyModelRootResource, MyModelResource +from djangorestframework.views import ListOrCreateModelView, InstanceModelView +from djangorestframework.resources import ModelResource +from modelresourceexample.models import MyModel + +class MyModelResource(ModelResource): + model = MyModel + fields = ('foo', 'bar', 'baz', 'url') + ordering = ('created',) urlpatterns = patterns('modelresourceexample.views', - url(r'^$', MyModelRootResource.as_view(), name='my-model-root-resource'), - url(r'^([0-9]+)/$', MyModelResource.as_view(), name='my-model-resource'), + url(r'^$', ListOrCreateModelView.as_view(resource=MyModelResource), name='model-resource-root'), + url(r'^([0-9]+)/$', InstanceModelView.as_view(resource=MyModelResource)), ) diff --git a/examples/modelresourceexample/views.py b/examples/modelresourceexample/views.py index 5495a2931..e69de29bb 100644 --- a/examples/modelresourceexample/views.py +++ b/examples/modelresourceexample/views.py @@ -1,16 +0,0 @@ -from djangorestframework.modelresource import InstanceModelResource, ListOrCreateModelResource -from modelresourceexample.models import MyModel - -FIELDS = ('foo', 'bar', 'baz', 'absolute_url') - -class MyModelRootResource(ListOrCreateModelResource): - """A create/list resource for MyModel. - Available for both authenticated and anonymous access for the purposes of the sandbox.""" - model = MyModel - fields = FIELDS - -class MyModelResource(InstanceModelResource): - """A read/update/delete resource for MyModel. - Available for both authenticated and anonymous access for the purposes of the sandbox.""" - model = MyModel - fields = FIELDS diff --git a/examples/objectstore/views.py b/examples/objectstore/views.py index 2e353e08c..076e59416 100644 --- a/examples/objectstore/views.py +++ b/examples/objectstore/views.py @@ -1,7 +1,7 @@ from django.conf import settings from django.core.urlresolvers import reverse -from djangorestframework.resource import Resource +from djangorestframework.views import BaseView from djangorestframework.response import Response from djangorestframework import status @@ -15,55 +15,69 @@ MAX_FILES = 10 def remove_oldest_files(dir, max_files): - """Remove the oldest files in a directory 'dir', leaving at most 'max_files' remaining. - We use this to limit the number of resources in the sandbox.""" + """ + Remove the oldest files in a directory 'dir', leaving at most 'max_files' remaining. + We use this to limit the number of resources in the sandbox. + """ filepaths = [os.path.join(dir, file) for file in os.listdir(dir) if not file.startswith('.')] ctime_sorted_paths = [item[0] for item in sorted([(path, os.path.getctime(path)) for path in filepaths], key=operator.itemgetter(1), reverse=True)] [os.remove(path) for path in ctime_sorted_paths[max_files:]] -class ObjectStoreRoot(Resource): - """Root of the Object Store API. - Allows the client to get a complete list of all the stored objects, or to create a new stored object.""" - allowed_methods = anon_allowed_methods = ('GET', 'POST') +class ObjectStoreRoot(BaseView): + """ + Root of the Object Store API. + Allows the client to get a complete list of all the stored objects, or to create a new stored object. + """ - def get(self, request, auth): - """Return a list of all the stored object URLs. (Ordered by creation time, newest first)""" + def get(self, request): + """ + Return a list of all the stored object URLs. (Ordered by creation time, newest first) + """ filepaths = [os.path.join(OBJECT_STORE_DIR, file) for file in os.listdir(OBJECT_STORE_DIR) if not file.startswith('.')] ctime_sorted_basenames = [item[0] for item in sorted([(os.path.basename(path), os.path.getctime(path)) for path in filepaths], key=operator.itemgetter(1), reverse=True)] return [reverse('stored-object', kwargs={'key':key}) for key in ctime_sorted_basenames] - def post(self, request, auth, content): - """Create a new stored object, with a unique key.""" + def post(self, request): + """ + Create a new stored object, with a unique key. + """ key = str(uuid.uuid1()) pathname = os.path.join(OBJECT_STORE_DIR, key) - pickle.dump(content, open(pathname, 'wb')) + pickle.dump(self.CONTENT, open(pathname, 'wb')) remove_oldest_files(OBJECT_STORE_DIR, MAX_FILES) - return Response(status.HTTP_201_CREATED, content, {'Location': reverse('stored-object', kwargs={'key':key})}) + return Response(status.HTTP_201_CREATED, self.CONTENT, {'Location': reverse('stored-object', kwargs={'key':key})}) -class StoredObject(Resource): - """Represents a stored object. - The object may be any picklable content.""" - allowed_methods = anon_allowed_methods = ('GET', 'PUT', 'DELETE') +class StoredObject(BaseView): + """ + Represents a stored object. + The object may be any picklable content. + """ - def get(self, request, auth, key): - """Return a stored object, by unpickling the contents of a locally stored file.""" + def get(self, request, key): + """ + Return a stored object, by unpickling the contents of a locally stored file. + """ pathname = os.path.join(OBJECT_STORE_DIR, key) if not os.path.exists(pathname): return Response(status.HTTP_404_NOT_FOUND) return pickle.load(open(pathname, 'rb')) - def put(self, request, auth, content, key): - """Update/create a stored object, by pickling the request content to a locally stored file.""" + def put(self, request, key): + """ + Update/create a stored object, by pickling the request content to a locally stored file. + """ pathname = os.path.join(OBJECT_STORE_DIR, key) - pickle.dump(content, open(pathname, 'wb')) - return content + pickle.dump(self.CONTENT, open(pathname, 'wb')) + return self.CONTENT - def delete(self, request, auth, key): - """Delete a stored object, by removing it's pickled file.""" + def delete(self, request): + """ + Delete a stored object, by removing it's pickled file. + """ pathname = os.path.join(OBJECT_STORE_DIR, key) if not os.path.exists(pathname): return Response(status.HTTP_404_NOT_FOUND) diff --git a/examples/pygments_api/views.py b/examples/pygments_api/views.py index 253b09070..e6bfae489 100644 --- a/examples/pygments_api/views.py +++ b/examples/pygments_api/views.py @@ -2,9 +2,10 @@ 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.resources import FormResource from djangorestframework.response import Response from djangorestframework.renderers import BaseRenderer +from djangorestframework.views import BaseView from djangorestframework import status from pygments.formatters import HtmlFormatter @@ -17,39 +18,60 @@ import os import uuid import operator -# We need somewhere to store the code that we highlight +# We need somewhere to store the code snippets that we highlight HIGHLIGHTED_CODE_DIR = os.path.join(settings.MEDIA_ROOT, 'pygments') MAX_FILES = 10 + def list_dir_sorted_by_ctime(dir): - """Return a list of files sorted by creation time""" + """ + Return a list of files sorted by creation time + """ filepaths = [os.path.join(dir, file) for file in os.listdir(dir) if not file.startswith('.')] - return [item[0] for item in sorted([(path, os.path.getctime(path)) for path in filepaths], - key=operator.itemgetter(1), reverse=False)] + return [item[0] for item in sorted( [(path, os.path.getctime(path)) for path in filepaths], + key=operator.itemgetter(1), reverse=False) ] + def remove_oldest_files(dir, max_files): - """Remove the oldest files in a directory 'dir', leaving at most 'max_files' remaining. - We use this to limit the number of resources in the sandbox.""" + """ + Remove the oldest files in a directory 'dir', leaving at most 'max_files' remaining. + We use this to limit the number of resources in the sandbox. + """ [os.remove(path) for path in list_dir_sorted_by_ctime(dir)[max_files:]] class HTMLRenderer(BaseRenderer): - """Basic renderer which just returns the content without any further serialization.""" + """ + Basic renderer which just returns the content without any further serialization. + """ media_type = 'text/html' -class PygmentsRoot(Resource): - """This example demonstrates a simple RESTful Web API aound the awesome pygments library. - This top level resource is used to create highlighted code snippets, and to list all the existing code snippets.""" + +class PygmentsFormResource(FormResource): + """ + """ form = PygmentsForm + +class PygmentsRoot(BaseView): + """ + This example demonstrates a simple RESTful Web API aound the awesome pygments library. + This top level resource is used to create highlighted code snippets, and to list all the existing code snippets. + """ + resource = PygmentsFormResource + def get(self, request): - """Return a list of all currently existing snippets.""" + """ + Return a list of all currently existing snippets. + """ unique_ids = [os.path.split(f)[1] for f in list_dir_sorted_by_ctime(HIGHLIGHTED_CODE_DIR)] return [reverse('pygments-instance', args=[unique_id]) for unique_id in unique_ids] def post(self, request): - """Create a new highlighed snippet and return it's location. - For the purposes of the sandbox example, also ensure we delete the oldest snippets if we have > MAX_FILES.""" + """ + Create a new highlighed snippet and return it's location. + For the purposes of the sandbox example, also ensure we delete the oldest snippets if we have > MAX_FILES. + """ unique_id = str(uuid.uuid1()) pathname = os.path.join(HIGHLIGHTED_CODE_DIR, unique_id) @@ -66,20 +88,26 @@ class PygmentsRoot(Resource): return Response(status.HTTP_201_CREATED, headers={'Location': reverse('pygments-instance', args=[unique_id])}) -class PygmentsInstance(Resource): - """Simply return the stored highlighted HTML file with the correct mime type. - This Resource only renders HTML and uses a standard HTML renderer rather than the renderers.DocumentingHTMLRenderer class.""" +class PygmentsInstance(BaseView): + """ + Simply return the stored highlighted HTML file with the correct mime type. + This Resource only renders HTML and uses a standard HTML renderer rather than the renderers.DocumentingHTMLRenderer class. + """ renderers = (HTMLRenderer,) def get(self, request, unique_id): - """Return the highlighted snippet.""" + """ + Return the highlighted snippet. + """ pathname = os.path.join(HIGHLIGHTED_CODE_DIR, unique_id) if not os.path.exists(pathname): return Response(status.HTTP_404_NOT_FOUND) return open(pathname, 'r').read() def delete(self, request, unique_id): - """Delete the highlighted snippet.""" + """ + Delete the highlighted snippet. + """ pathname = os.path.join(HIGHLIGHTED_CODE_DIR, unique_id) if not os.path.exists(pathname): return Response(status.HTTP_404_NOT_FOUND) diff --git a/examples/resourceexample/views.py b/examples/resourceexample/views.py index 911fd467b..70d96891b 100644 --- a/examples/resourceexample/views.py +++ b/examples/resourceexample/views.py @@ -1,20 +1,33 @@ from django.core.urlresolvers import reverse -from djangorestframework.resource import Resource +from djangorestframework.views import BaseView +from djangorestframework.resources import FormResource from djangorestframework.response import Response from djangorestframework import status from resourceexample.forms import MyForm -class ExampleResource(Resource): - """A basic read-only resource that points to 3 other resources.""" +class MyFormValidation(FormResource): + """ + A resource which applies form validation on the input. + """ + form = MyForm + + +class ExampleResource(BaseView): + """ + A basic read-only resource that points to 3 other resources. + """ def get(self, request): 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.""" - form = MyForm # Optional form validation on input (Applies in this case the POST method, but can also apply to PUT) + +class AnotherExampleResource(BaseView): + """ + A basic GET-able/POST-able resource. + """ + resource = MyFormValidation def get(self, request, num): """Handle GET requests""" diff --git a/examples/sandbox/views.py b/examples/sandbox/views.py index 78b722caf..d5b59284b 100644 --- a/examples/sandbox/views.py +++ b/examples/sandbox/views.py @@ -27,8 +27,8 @@ class Sandbox(BaseView): def get(self, request): return [{'name': 'Simple Resource example', 'url': reverse('example-resource')}, - {'name': 'Simple ModelResource example', 'url': reverse('my-model-root-resource')}, + {'name': 'Simple ModelResource example', 'url': reverse('model-resource-root')}, {'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')}] + {'name': 'Blog posts API', 'url': reverse('blog-posts-root')}] From 75cc1eb2a85c2e8ef872566c0b798c6526a23cb3 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 16 May 2011 15:57:10 +0100 Subject: [PATCH 48/70] blogpost/views no longer needed --- examples/blogpost/views.py | 27 --------------------------- 1 file changed, 27 deletions(-) delete mode 100644 examples/blogpost/views.py diff --git a/examples/blogpost/views.py b/examples/blogpost/views.py deleted file mode 100644 index c4b54f734..000000000 --- a/examples/blogpost/views.py +++ /dev/null @@ -1,27 +0,0 @@ -from djangorestframework.modelresource import InstanceModelResource, ListOrCreateModelResource - -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 BlogPosts(ListOrCreateModelResource): - """A resource with which lists all existing blog posts and creates new blog posts.""" - model = models.BlogPost - fields = BLOG_POST_FIELDS - -class BlogPostInstance(InstanceModelResource): - """A resource which represents a single blog post.""" - model = models.BlogPost - fields = BLOG_POST_FIELDS - -class Comments(ListOrCreateModelResource): - """A resource which lists all existing comments for a given blog post, and creates new blog comments for a given blog post.""" - model = models.Comment - fields = COMMENT_FIELDS - -class CommentInstance(InstanceModelResource): - """A resource which represents a single comment.""" - model = models.Comment - fields = COMMENT_FIELDS - From 3470134373658f4f332d6b6fba038ef7b43bbf9f Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 16 May 2011 16:00:00 +0100 Subject: [PATCH 49/70] Add utils to setup.py - see if the examples tests now pass --- setup.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/setup.py b/setup.py index 1aa6e1908..e5dc4e0e2 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,8 @@ setup( packages = ['djangorestframework', 'djangorestframework.templatetags', 'djangorestframework.tests', - 'djangorestframework.runtests'], + 'djangorestframework.runtests', + 'djangorestframework.utils'], package_dir={'djangorestframework': 'djangorestframework'}, package_data = {'djangorestframework': ['templates/*', 'static/*']}, test_suite = 'djangorestframework.runtests.runcoverage.main', @@ -31,7 +32,4 @@ setup( ] ) -import os, shutil -shutil.rmtree(os.path.join(os.path.dirname(__file__), 'djangorestframework.egg-info'), True) - From 3039f6f6c29d0b469810adcd6ba450361f2a16de Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 16 May 2011 16:52:39 +0100 Subject: [PATCH 50/70] validators tests -> resources tests --- djangorestframework/resources.py | 10 +- djangorestframework/tests/validators.py | 139 +++++++++++++----------- 2 files changed, 81 insertions(+), 68 deletions(-) diff --git a/djangorestframework/resources.py b/djangorestframework/resources.py index 8a9373cfc..fa3dd1b7e 100644 --- a/djangorestframework/resources.py +++ b/djangorestframework/resources.py @@ -188,9 +188,13 @@ class FormResource(Resource): def _validate(self, data, files, allowed_extra_fields=(), fake_data=None): """ - 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 + Wrapped by validate to hide the extra flags that are used in the implementation. + + allowed_extra_fields is a list of fields which are not defined by the form, but which we still expect to see on the input. + + fake_data is a string that should be used as an extra key, as a kludge to force .errors + to be populated when an empty dict is supplied in `data` """ # We'd like nice error messages even if no content is supplied. @@ -369,7 +373,7 @@ class ModelResource(FormResource): if self.form: # Use explict Form - return super(ModelFormValidator, self).get_bound_form(data, files) + return super(ModelResource, self).get_bound_form(data, files) elif self.model: # Fall back to ModelForm which we create on the fly diff --git a/djangorestframework/tests/validators.py b/djangorestframework/tests/validators.py index fb09c5baa..32fb10029 100644 --- a/djangorestframework/tests/validators.py +++ b/djangorestframework/tests/validators.py @@ -2,62 +2,62 @@ from django import forms from django.db import models from django.test import TestCase from djangorestframework.compat import RequestFactory -from djangorestframework.validators import BaseValidator, FormValidator, ModelFormValidator +from djangorestframework.resources import Resource, FormResource, ModelResource from djangorestframework.response import ErrorResponse from djangorestframework.views import BaseView from djangorestframework.resources import Resource -class TestValidatorMixinInterfaces(TestCase): - """Basic tests to ensure that the ValidatorMixin classes expose the expected interfaces""" - - def test_validator_mixin_interface(self): - """Ensure the ValidatorMixin base class interface is as expected.""" - self.assertRaises(NotImplementedError, BaseValidator(None).validate, None) - class TestDisabledValidations(TestCase): """Tests on FormValidator with validation disabled by setting form to None""" def test_disabled_form_validator_returns_content_unchanged(self): - """If the view's form attribute is None then FormValidator(view).validate(content) + """If the view's form attribute is None then FormValidator(view).validate_request(content, None) should just return the content unmodified.""" - class DisabledFormView(BaseView): + class DisabledFormResource(FormResource): form = None - view = DisabledFormView() + class MockView(BaseView): + resource = DisabledFormResource + + view = MockView() content = {'qwerty':'uiop'} - self.assertEqual(FormValidator(view).validate(content), content) + self.assertEqual(FormResource(view).validate_request(content, None), content) def test_disabled_form_validator_get_bound_form_returns_none(self): """If the view's form attribute is None on then FormValidator(view).get_bound_form(content) should just return None.""" - class DisabledFormView(BaseView): + class DisabledFormResource(FormResource): form = None - view = DisabledFormView() + class MockView(BaseView): + resource = DisabledFormResource + + view = MockView() content = {'qwerty':'uiop'} - self.assertEqual(FormValidator(view).get_bound_form(content), None) + self.assertEqual(FormResource(view).get_bound_form(content), None) def test_disabled_model_form_validator_returns_content_unchanged(self): """If the view's form is None and does not have a Resource with a model set then - ModelFormValidator(view).validate(content) should just return the content unmodified.""" + ModelFormValidator(view).validate_request(content, None) should just return the content unmodified.""" + class DisabledModelFormView(BaseView): - form = None + resource = ModelResource view = DisabledModelFormView() content = {'qwerty':'uiop'} - self.assertEqual(ModelFormValidator(view).get_bound_form(content), None)# + self.assertEqual(ModelResource(view).get_bound_form(content), None)# 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 DisabledModelFormView(BaseView): - model = None + resource = ModelResource view = DisabledModelFormView() content = {'qwerty':'uiop'} - self.assertEqual(ModelFormValidator(view).get_bound_form(content), None) + self.assertEqual(ModelResource(view).get_bound_form(content), None) class TestNonFieldErrors(TestCase): """Tests against form validation errors caused by non-field errors. (eg as might be caused by some custom form validation)""" @@ -74,13 +74,16 @@ class TestNonFieldErrors(TestCase): raise forms.ValidationError(self.ERROR_TEXT) return self.cleaned_data #pragma: no cover - class MockView(object): + class MockResource(FormResource): form = MockForm + class MockView(BaseView): + pass + view = MockView() content = {'field1': 'example1', 'field2': 'example2'} try: - FormValidator(view).validate(content) + MockResource(view).validate_request(content, None) except ErrorResponse, exc: self.assertEqual(exc.response.raw_content, {'errors': [MockForm.ERROR_TEXT]}) else: @@ -95,14 +98,20 @@ class TestFormValidation(TestCase): class MockForm(forms.Form): qwerty = forms.CharField(required=True) - class MockFormView(BaseView): + class MockFormResource(FormResource): form = MockForm - validators = (FormValidator,) + + class MockModelResource(ModelResource): + form = MockForm + + class MockFormView(BaseView): + resource = MockFormResource class MockModelFormView(BaseView): - form = MockForm - validators = (ModelFormValidator,) - + resource = MockModelResource + + self.MockFormResource = MockFormResource + self.MockModelResource = MockModelResource self.MockFormView = MockFormView self.MockModelFormView = MockModelFormView @@ -110,35 +119,35 @@ class TestFormValidation(TestCase): 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) + self.assertEqual(validator.validate_request(content, None), content) def validation_failure_raises_response_exception(self, validator): """If form validation fails a ResourceException 400 (Bad Request) should be raised.""" - content = {} - self.assertRaises(ErrorResponse, validator.validate, content) + content = {} + self.assertRaises(ErrorResponse, validator.validate_request, content, None) 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(ErrorResponse, validator.validate, content) + self.assertRaises(ErrorResponse, validator.validate_request, content, None) 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',)) + validator._validate(content, None, 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) + self.assertEqual(validator._validate(content, None, 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) + validator.validate_request(content, None) except ErrorResponse, exc: self.assertEqual(exc.response.raw_content, {'field-errors': {'qwerty': ['This field is required.']}}) else: @@ -148,7 +157,7 @@ class TestFormValidation(TestCase): """If validation fails due to a field error, ensure the response contains a single field error""" content = {'qwerty': ''} try: - validator.validate(content) + validator.validate_request(content, None) except ErrorResponse, exc: self.assertEqual(exc.response.raw_content, {'field-errors': {'qwerty': ['This field is required.']}}) else: @@ -158,7 +167,7 @@ class TestFormValidation(TestCase): """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) + validator.validate_request(content, None) except ErrorResponse, exc: self.assertEqual(exc.response.raw_content, {'field-errors': {'extra': ['This field does not exist.']}}) else: @@ -168,87 +177,87 @@ class TestFormValidation(TestCase): """If validation for multiple reasons, ensure the response contains each error""" content = {'qwerty': '', 'extra': 'extra'} try: - validator.validate(content) + validator.validate_request(content, None) except ErrorResponse, 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 + # Tests on FormResource def test_form_validation_returns_content_unchanged_if_already_valid_and_clean(self): - validator = FormValidator(self.MockFormView()) + validator = self.MockFormResource(self.MockFormView()) self.validation_returns_content_unchanged_if_already_valid_and_clean(validator) def test_form_validation_failure_raises_response_exception(self): - validator = FormValidator(self.MockFormView()) + validator = self.MockFormResource(self.MockFormView()) self.validation_failure_raises_response_exception(validator) def test_validation_does_not_allow_extra_fields_by_default(self): - validator = FormValidator(self.MockFormView()) + validator = self.MockFormResource(self.MockFormView()) self.validation_does_not_allow_extra_fields_by_default(validator) def test_validation_allows_extra_fields_if_explicitly_set(self): - validator = FormValidator(self.MockFormView()) + validator = self.MockFormResource(self.MockFormView()) self.validation_allows_extra_fields_if_explicitly_set(validator) def test_validation_does_not_require_extra_fields_if_explicitly_set(self): - validator = FormValidator(self.MockFormView()) + validator = self.MockFormResource(self.MockFormView()) self.validation_does_not_require_extra_fields_if_explicitly_set(validator) def test_validation_failed_due_to_no_content_returns_appropriate_message(self): - validator = FormValidator(self.MockFormView()) + validator = self.MockFormResource(self.MockFormView()) self.validation_failed_due_to_no_content_returns_appropriate_message(validator) def test_validation_failed_due_to_field_error_returns_appropriate_message(self): - validator = FormValidator(self.MockFormView()) + validator = self.MockFormResource(self.MockFormView()) self.validation_failed_due_to_field_error_returns_appropriate_message(validator) def test_validation_failed_due_to_invalid_field_returns_appropriate_message(self): - validator = FormValidator(self.MockFormView()) + validator = self.MockFormResource(self.MockFormView()) self.validation_failed_due_to_invalid_field_returns_appropriate_message(validator) def test_validation_failed_due_to_multiple_errors_returns_appropriate_message(self): - validator = FormValidator(self.MockFormView()) + validator = self.MockFormResource(self.MockFormView()) self.validation_failed_due_to_multiple_errors_returns_appropriate_message(validator) - # Same tests on ModelFormValidtionMixin + # Same tests on ModelResource def test_modelform_validation_returns_content_unchanged_if_already_valid_and_clean(self): - validator = ModelFormValidator(self.MockModelFormView()) + validator = self.MockModelResource(self.MockModelFormView()) self.validation_returns_content_unchanged_if_already_valid_and_clean(validator) def test_modelform_validation_failure_raises_response_exception(self): - validator = ModelFormValidator(self.MockModelFormView()) + validator = self.MockModelResource(self.MockModelFormView()) self.validation_failure_raises_response_exception(validator) def test_modelform_validation_does_not_allow_extra_fields_by_default(self): - validator = ModelFormValidator(self.MockModelFormView()) + validator = self.MockModelResource(self.MockModelFormView()) self.validation_does_not_allow_extra_fields_by_default(validator) def test_modelform_validation_allows_extra_fields_if_explicitly_set(self): - validator = ModelFormValidator(self.MockModelFormView()) + validator = self.MockModelResource(self.MockModelFormView()) self.validation_allows_extra_fields_if_explicitly_set(validator) def test_modelform_validation_does_not_require_extra_fields_if_explicitly_set(self): - validator = ModelFormValidator(self.MockModelFormView()) + validator = self.MockModelResource(self.MockModelFormView()) self.validation_does_not_require_extra_fields_if_explicitly_set(validator) def test_modelform_validation_failed_due_to_no_content_returns_appropriate_message(self): - validator = ModelFormValidator(self.MockModelFormView()) + validator = self.MockModelResource(self.MockModelFormView()) self.validation_failed_due_to_no_content_returns_appropriate_message(validator) def test_modelform_validation_failed_due_to_field_error_returns_appropriate_message(self): - validator = ModelFormValidator(self.MockModelFormView()) + validator = self.MockModelResource(self.MockModelFormView()) self.validation_failed_due_to_field_error_returns_appropriate_message(validator) def test_modelform_validation_failed_due_to_invalid_field_returns_appropriate_message(self): - validator = ModelFormValidator(self.MockModelFormView()) + validator = self.MockModelResource(self.MockModelFormView()) self.validation_failed_due_to_invalid_field_returns_appropriate_message(validator) def test_modelform_validation_failed_due_to_multiple_errors_returns_appropriate_message(self): - validator = ModelFormValidator(self.MockModelFormView()) + validator = self.MockModelResource(self.MockModelFormView()) self.validation_failed_due_to_multiple_errors_returns_appropriate_message(validator) @@ -265,43 +274,43 @@ class TestModelFormValidator(TestCase): def readonly(self): return 'read only' - class MockResource(Resource): + class MockResource(ModelResource): model = MockModel class MockView(BaseView): resource = MockResource - self.validator = ModelFormValidator(MockView) + self.validator = MockResource(MockView) 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.validator.validate(content), content) + self.assertEqual(self.validator.validate_request(content, None), 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.validator.validate(content), content) + self.assertEqual(self.validator.validate_request(content, None), 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(ErrorResponse, self.validator.validate, content) + content = {'qwerty': 'example', 'uiop':'example', 'readonly': 'read only', 'extra': 'extra'} + self.assertRaises(ErrorResponse, self.validator.validate_request, content, None) 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(ErrorResponse, self.validator.validate, content) + self.assertRaises(ErrorResponse, self.validator.validate_request, content, None) 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.validator.validate(content) + self.validator.validate_request(content, None) def test_model_form_validator_uses_model_forms(self): self.assertTrue(isinstance(self.validator.get_bound_form(), forms.ModelForm)) From 304786657a8cbff323f21b7297c142f6ca5a1cc1 Mon Sep 17 00:00:00 2001 From: markotibold Date: Mon, 16 May 2011 23:54:35 +0200 Subject: [PATCH 51/70] Getting rid of all errors and warnings that show up when building the docs, to make a clean start with the library documentation . --HG-- rename : docs/library/authenticators.rst => docs/library/authentication.rst rename : docs/library/emitters.rst => docs/library/renderers.rst --- docs/examples/blogpost.rst | 6 +++--- docs/index.rst | 8 ++++---- docs/library/authentication.rst | 5 +++++ docs/library/authenticators.rst | 5 ----- docs/library/emitters.rst | 7 ------- docs/library/modelresource.rst | 9 --------- docs/library/renderers.rst | 10 ++++++++++ 7 files changed, 22 insertions(+), 28 deletions(-) create mode 100644 docs/library/authentication.rst delete mode 100644 docs/library/authenticators.rst delete mode 100644 docs/library/emitters.rst delete mode 100644 docs/library/modelresource.rst create mode 100644 docs/library/renderers.rst diff --git a/docs/examples/blogpost.rst b/docs/examples/blogpost.rst index 07f7cbc56..9d762f528 100644 --- a/docs/examples/blogpost.rst +++ b/docs/examples/blogpost.rst @@ -27,7 +27,7 @@ Creating the resources Once we have some existing models there's very little we need to do to create the corresponding resources. We simply create a base resource and an instance resource for each model we're working with. django-rest-framework will provide the default operations on the resources all the usual input validation that Django's models can give us for free. -``views.py`` +#``views.py`` -.. include:: ../../examples/blogpost/views.py - :literal: \ No newline at end of file +#.. include:: ../../examples/blogpost/views.py +# :literal: \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 4da2da1cb..fac1c94bc 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -36,7 +36,8 @@ Resources Any and all questions, thoughts, bug reports and contributions are *hugely appreciated*. -We'd like for this to be a real community driven effort, so come say hi, get involved, and get forking! (See: `Bitbucket `_, `GitHub `_) +We'd like for this to be a real community driven effort, so come say hi, get involved, and get forking! (See: `Forking a Bitbucket Repository +`_, or `Fork A GitHub Repo `_) Requirements ------------ @@ -140,10 +141,9 @@ Library Reference :maxdepth: 1 library/resource - library/modelresource - library/emitters + library/renderers library/parsers - library/authenticators + library/authentication library/validators library/response library/status diff --git a/docs/library/authentication.rst b/docs/library/authentication.rst new file mode 100644 index 000000000..d159f6054 --- /dev/null +++ b/docs/library/authentication.rst @@ -0,0 +1,5 @@ +:mod:`authentication` +===================== + +.. automodule:: authentication + :members: diff --git a/docs/library/authenticators.rst b/docs/library/authenticators.rst deleted file mode 100644 index 407339f78..000000000 --- a/docs/library/authenticators.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`authenticators` -===================== - -.. automodule:: authenticators - :members: diff --git a/docs/library/emitters.rst b/docs/library/emitters.rst deleted file mode 100644 index 590ace0fa..000000000 --- a/docs/library/emitters.rst +++ /dev/null @@ -1,7 +0,0 @@ -:mod:`emitters` -=============== - -The emitters module provides a set of emitters that can be plugged in to a :class:`.Resource`. An emitter is responsible for taking the output of a and serializing it to a given media type. A :class:`.Resource` can have a number of emitters, allow the same content to be serialized in a number of different formats depending on the requesting client's preferences, as specified in the HTTP Request's Accept header. - -.. automodule:: emitters - :members: diff --git a/docs/library/modelresource.rst b/docs/library/modelresource.rst deleted file mode 100644 index af7609442..000000000 --- a/docs/library/modelresource.rst +++ /dev/null @@ -1,9 +0,0 @@ -:mod:`modelresource` -==================== - -.. note:: - - TODO - document this module properly - -.. automodule:: modelresource - :members: diff --git a/docs/library/renderers.rst b/docs/library/renderers.rst new file mode 100644 index 000000000..a9e729316 --- /dev/null +++ b/docs/library/renderers.rst @@ -0,0 +1,10 @@ +:mod:`renderers` +================ + +The renderers module provides a set of renderers that can be plugged in to a :class:`.Resource`. +A renderer is responsible for taking the output of a View and serializing it to a given media type. +A :class:`.Resource` can have a number of renderers, allow the same content to be serialized in a number +of different formats depending on the requesting client's preferences, as specified in the HTTP Request's Accept header. + +.. automodule:: renderers + :members: From 66b9bda9bf9492c5e1ffba162e044aacb51bbd7e Mon Sep 17 00:00:00 2001 From: markotibold Date: Tue, 17 May 2011 00:18:45 +0200 Subject: [PATCH 52/70] All top level modules are included. Ready for diving into the modules and documenting/ enhancing already existing docs. --- djangorestframework/views.py | 6 +----- docs/index.rst | 12 ++++++++---- docs/library/compat.rst | 5 +++++ docs/library/mixins.rst | 5 +++++ docs/library/permissions.rst | 5 +++++ docs/library/views.rst | 5 +++++ 6 files changed, 29 insertions(+), 9 deletions(-) create mode 100644 docs/library/compat.rst create mode 100644 docs/library/mixins.rst create mode 100644 docs/library/permissions.rst create mode 100644 docs/library/views.rst diff --git a/djangorestframework/views.py b/djangorestframework/views.py index 2e7e8418a..81567e687 100644 --- a/djangorestframework/views.py +++ b/djangorestframework/views.py @@ -11,7 +11,7 @@ __all__ = ( 'BaseView', 'ModelView', 'InstanceModelView', - 'ListOrModelView', + 'ListModelView', 'ListOrCreateModelView' ) @@ -131,7 +131,3 @@ class ListModelView(ListModelMixin, ModelView): class ListOrCreateModelView(ListModelMixin, CreateModelMixin, ModelView): """A view which provides default operations for list and create, against a model in the database.""" pass - - - - diff --git a/docs/index.rst b/docs/index.rst index fac1c94bc..75569d457 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -140,13 +140,17 @@ Library Reference .. toctree:: :maxdepth: 1 - library/resource - library/renderers - library/parsers library/authentication - library/validators + library/compat + library/mixins + library/parsers + library/permissions + library/renderers + library/resource library/response library/status + library/validators + library/views Examples Reference ------------------ diff --git a/docs/library/compat.rst b/docs/library/compat.rst new file mode 100644 index 000000000..93fb081a0 --- /dev/null +++ b/docs/library/compat.rst @@ -0,0 +1,5 @@ +:mod:`compat` +===================== + +.. automodule:: compat + :members: diff --git a/docs/library/mixins.rst b/docs/library/mixins.rst new file mode 100644 index 000000000..04bf66b06 --- /dev/null +++ b/docs/library/mixins.rst @@ -0,0 +1,5 @@ +:mod:`mixins` +===================== + +.. automodule:: mixins + :members: diff --git a/docs/library/permissions.rst b/docs/library/permissions.rst new file mode 100644 index 000000000..c694d639a --- /dev/null +++ b/docs/library/permissions.rst @@ -0,0 +1,5 @@ +:mod:`permissions` +===================== + +.. automodule:: permissions + :members: diff --git a/docs/library/views.rst b/docs/library/views.rst new file mode 100644 index 000000000..329b487b7 --- /dev/null +++ b/docs/library/views.rst @@ -0,0 +1,5 @@ +:mod:`views` +===================== + +.. automodule:: views + :members: From 40573b2793a49d68b89ea5b6c4bff0e13470cc0c Mon Sep 17 00:00:00 2001 From: markotibold Date: Tue, 17 May 2011 01:27:27 +0200 Subject: [PATCH 53/70] Nicely marked up source code. --- djangorestframework/authentication.py | 31 +++++++++++++++------------ 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/djangorestframework/authentication.py b/djangorestframework/authentication.py index b0ba41aae..e3e44ffbe 100644 --- a/djangorestframework/authentication.py +++ b/djangorestframework/authentication.py @@ -1,10 +1,10 @@ """ -The ``authentication`` module provides a set of pluggable authentication classes. +The :mod:`authentication` module provides a set of pluggable authentication classes. -Authentication behavior is provided by adding the ``AuthMixin`` class to a ``View`` . +Authentication behavior is provided by mixing the :class:`mixins.AuthMixin` class into a :class:`View` class. The set of authentication methods which are used is then specified by setting the -``authentication`` attribute on the ``View`` class, and listing a set of authentication classes. +:attr:`authentication` attribute on the :class:`View` class, and listing a set of authentication classes. """ from django.contrib.auth import authenticate @@ -22,28 +22,31 @@ __all__ = ( class BaseAuthenticaton(object): """ All authentication classes should extend BaseAuthentication. + + :param view: :class:`Authentication` classes are always passed the current view on creation. """ def __init__(self, view): """ - Authentication classes are always passed the current view on creation. """ self.view = view def authenticate(self, request): """ - Authenticate the request and return a ``User`` instance or None. (*) + :param request: Request to be authenticated + :rtype: :obj:`User` object or None [*]_ - This function must be overridden to be implemented. + .. Note:: + This function must be overridden to be implemented. - (*) The authentication context _will_ typically be a ``User`` object, - but it need not be. It can be any user-like object so long as the - permissions classes on the view can handle the object and use - it to determine if the request has the required permissions or not. - - This can be an important distinction if you're implementing some token - based authentication mechanism, where the authentication context - may be more involved than simply mapping to a ``User``. + .. [*] The authentication context *will* typically be a :obj:`User` object, + but it need not be. It can be any user-like object so long as the + permissions classes on the view can handle the object and use + it to determine if the request has the required permissions or not. + + This can be an important distinction if you're implementing some token + based authentication mechanism, where the authentication context + may be more involved than simply mapping to a :obj:`User`. """ return None From 80d7d5be6744aec06c614e128140e9e30fd81ac7 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 17 May 2011 08:20:03 +0100 Subject: [PATCH 54/70] Added Alex Ehlke to AUTHORS --- AUTHORS | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS b/AUTHORS index 85f577bfe..103423ab4 100644 --- a/AUTHORS +++ b/AUTHORS @@ -4,6 +4,7 @@ Paul Bagwell - Suggestions & bugfixes. Marko Tibold - Contributions & Providing the Hudson CI Server. Sébastien Piquemal - Contributions. Carmen Wick - Bugfixes. +Alex Ehlke - Design Contributions. THANKS TO: Jesper Noehr & the django-piston contributors for providing the starting point for this project. From 3f6b6e437bf24a55c33af5379b8ae89974edba57 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 17 May 2011 08:21:35 +0100 Subject: [PATCH 55/70] Remove validators (logic into resources) --- djangorestframework/validators.py | 228 ------------------------------ 1 file changed, 228 deletions(-) delete mode 100644 djangorestframework/validators.py diff --git a/djangorestframework/validators.py b/djangorestframework/validators.py deleted file mode 100644 index bef85af77..000000000 --- a/djangorestframework/validators.py +++ /dev/null @@ -1,228 +0,0 @@ -"""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 ErrorResponse -from djangorestframework.utils import as_tuple - - -class BaseValidator(object): - """Base class for all Validator classes, which simply defines the interface they provide.""" - - def __init__(self, view): - self.view = view - - def validate(self, content): - """Given some content as input return some cleaned, validated content. - Typically raises a ErrorResponse with status code 400 (Bad Request) on failure. - - Must be overridden to be implemented.""" - raise NotImplementedError() - - -class FormValidator(BaseValidator): - """Validator class that uses forms for validation. - Also provides a get_bound_form() method which may be used by some renderers. - - The view class should provide `.form` attribute which specifies the form classmethod - to be used for validation. - - On calling validate() this validator may set a `.bound_form_instance` attribute on the - view, which may be used by some renderers.""" - - - def validate(self, content): - """ - Given some content as input return some cleaned, validated content. - Raises a ErrorResponse 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 ErrorResponse 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, 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. - """ - bound_form = self.get_bound_form(content) - - if bound_form is None: - return content - - self.view.bound_form_instance = bound_form - - 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: - # Validation succeeded... - cleaned_data = bound_form.cleaned_data - - cleaned_data.update(bound_form.files) - - # 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: - 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.'] - - if field_errors: - detail[u'field-errors'] = field_errors - - # Return HTTP 400 response (BAD REQUEST) - raise ErrorResponse(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.""" - form_cls = getattr(self.view, 'form', None) - - if not form_cls: - return None - - if content is not None: - if hasattr(content, 'FILES'): - return form_cls(content, content.FILES) - return form_cls(content) - return form_cls() - - -class ModelFormValidator(FormValidator): - """Validator class that uses forms for validation and otherwise falls back to a model form if no form is set. - Also provides a get_bound_form() method which may be used by some renderers.""" - - """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. - - 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.) - def validate(self, content): - """ - Given some content as input return some cleaned, validated content. - Raises a ErrorResponse 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 ErrorResponse 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, allowed_extra_fields=self._property_fields_set) - - - 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.""" - - form_cls = getattr(self.view, 'form', None) - model_cls = getattr(self.view.resource, 'model', None) - - if form_cls: - # Use explict Form - return super(ModelFormValidator, self).get_bound_form(content) - - elif model_cls: - # Fall back to ModelForm which we create on the fly - class OnTheFlyModelForm(forms.ModelForm): - class Meta: - model = model_cls - #fields = tuple(self._model_fields_set) - - # Instantiate the ModelForm as appropriate - if content and isinstance(content, models.Model): - # Bound to an existing model instance - return OnTheFlyModelForm(instance=content) - elif not content is None: - if hasattr(content, 'FILES'): - return OnTheFlyModelForm(content, content.FILES) - return OnTheFlyModelForm(content) - return OnTheFlyModelForm() - - # Both form and model not set? Okay bruv, whatevs... - return None - - - @property - def _model_fields_set(self): - """Return a set containing the names of validated fields on the model.""" - resource = self.view.resource - model = getattr(resource, 'model', None) - fields = getattr(resource, 'fields', self.fields) - exclude_fields = getattr(resource, 'exclude_fields', self.exclude_fields) - - model_fields = set(field.name for field in model._meta.fields) - - if fields: - return model_fields & set(as_tuple(fields)) - - return model_fields - set(as_tuple(exclude_fields)) - - @property - def _property_fields_set(self): - """Returns a set containing the names of validated properties on the model.""" - resource = self.view.resource - model = getattr(resource, 'model', None) - fields = getattr(resource, 'fields', self.fields) - exclude_fields = getattr(resource, 'exclude_fields', self.exclude_fields) - - property_fields = set(attr for attr in dir(model) if - isinstance(getattr(model, attr, None), property) - and not attr.startswith('_')) - - if fields: - return property_fields & set(as_tuple(fields)) - - return property_fields - set(as_tuple(exclude_fields)) - - - From 49d4e50342cd3a6e2dce9f61d379cb54a0c1c6b5 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 17 May 2011 09:46:33 +0100 Subject: [PATCH 56/70] Better do this before I forget --- djangorestframework/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangorestframework/__init__.py b/djangorestframework/__init__.py index 522771b5b..264824353 100644 --- a/djangorestframework/__init__.py +++ b/djangorestframework/__init__.py @@ -1 +1 @@ -VERSION="0.1.1" +VERSION="0.2.0" From 92c015e0495b7cf39b0d0387fe6d376812a9ebef Mon Sep 17 00:00:00 2001 From: markotibold Date: Wed, 18 May 2011 22:13:48 +0200 Subject: [PATCH 57/70] Most of the actual work so far has been markup really. --- djangorestframework/authentication.py | 17 ++++----- djangorestframework/compat.py | 38 ++++++++++-------- djangorestframework/mixins.py | 55 +++++++++++++-------------- djangorestframework/parsers.py | 46 ++++++++++++---------- djangorestframework/permissions.py | 6 +-- djangorestframework/renderers.py | 10 ++--- 6 files changed, 92 insertions(+), 80 deletions(-) diff --git a/djangorestframework/authentication.py b/djangorestframework/authentication.py index 1c5c832f3..19a7aa90d 100644 --- a/djangorestframework/authentication.py +++ b/djangorestframework/authentication.py @@ -4,7 +4,7 @@ The :mod:`authentication` module provides a set of pluggable authentication clas Authentication behavior is provided by mixing the :class:`mixins.AuthMixin` class into a :class:`View` class. The set of authentication methods which are used is then specified by setting the -:attr:`authentication` attribute on the :class:`View` class, and listing a set of authentication classes. +:attr:`authentication` attribute on the :class:`View` class, and listing a set of :class:`authentication` classes. """ from django.contrib.auth import authenticate @@ -26,20 +26,19 @@ class BaseAuthenticaton(object): def __init__(self, view): """ - :param view: :class:`Authentication` classes are always passed the current view on creation. + :class:`Authentication` classes are always passed the current view on creation. """ self.view = view def authenticate(self, request): """ - :param request: Request to be authenticated - :rtype: :obj:`User` or None [*]_ + Authenticate the :obj:`request` and return a :obj:`User` instance or :const:`None`. [*]_ - .. [*] The authentication context *will* typically be a :obj:`User`, + .. [*] The authentication context *will* typically be a :obj:`User` object, but it need not be. It can be any user-like object so long as the - permissions classes on the view can handle the object and use + permissions classes (see the :mod:`permissions` module) on the view can handle the object and use it to determine if the request has the required permissions or not. - + This can be an important distinction if you're implementing some token based authentication mechanism, where the authentication context may be more involved than simply mapping to a :obj:`User`. @@ -55,7 +54,7 @@ class BasicAuthenticaton(BaseAuthenticaton): def authenticate(self, request): """ Returns a :obj:`User` if a correct username and password have been supplied - using HTTP Basic authentication. Otherwise returns `None`. + using HTTP Basic authentication. Otherwise returns :const:`None`. """ from django.utils.encoding import smart_unicode, DjangoUnicodeDecodeError @@ -85,7 +84,7 @@ class UserLoggedInAuthenticaton(BaseAuthenticaton): def authenticate(self, request): """ - Returns a :obj:`User` if the request session currently has a logged in user, otherwise `None`. + Returns a :obj:`User` object if the request session currently has a logged in user. Otherwise returns :const:`None`. """ # TODO: Switch this back to request.POST, and let FormParser/MultiPartParser deal with the consequences. if getattr(request, 'user', None) and request.user.is_active: diff --git a/djangorestframework/compat.py b/djangorestframework/compat.py index 98fbbb62c..45a695c8c 100644 --- a/djangorestframework/compat.py +++ b/djangorestframework/compat.py @@ -1,4 +1,6 @@ -"""Compatability module to provide support for backwards compatability with older versions of django/python""" +""" +Compatability module to provide support for backwards compatability with older versions of django/python +""" # cStringIO only if it's available try: @@ -27,24 +29,25 @@ except ImportError: # Lovely stuff class RequestFactory(Client): """ - Class that lets you create mock Request objects for use in testing. + Class that lets you create mock :obj:`Request` objects for use in testing. - Usage: + Usage:: - rf = RequestFactory() - get_request = rf.get('/hello/') - post_request = rf.post('/submit/', {'foo': 'bar'}) + 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 + This class re-uses the :class:`django.test.client.Client` interface. Of which + you can find the docs here__. - 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. + __ http://www.djangoproject.com/documentation/testing/#the-test-client + Once you have a :obj:`request` object you can pass it to any :func:`view` function, + just as if that :func:`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 + Similar to parent class, but returns the :obj:`request` object as soon as it has created it. """ environ = { @@ -148,9 +151,11 @@ try: import re class CustomSetextHeaderProcessor(markdown.blockprocessors.BlockProcessor): - """Override markdown's SetextHeaderProcessor, so that ==== headers are

and ---- headers are

. + """ + Override `markdown`'s :class:`SetextHeaderProcessor`, so that ==== headers are

and ---- headers are

. - We use

for the resource name.""" + We use

for the resource name. + """ # Detect Setext-style header. Must be first 2 lines of block. RE = re.compile(r'^.*?\n[=-]{3,}', re.MULTILINE) @@ -172,8 +177,11 @@ try: 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

.""" + """ + Simple wrapper around :func:`markdown.markdown` to apply our :class:`CustomSetextHeaderProcessor`, + and also set the base level of '#' style headers to

. + """ + extensions = ['headerid(level=2)'] safe_mode = False, output_format = markdown.DEFAULT_OUTPUT_FORMAT diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index e101b7883..524e9cce7 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -1,5 +1,6 @@ """ -The mixins module provides a set of reusable mixin classes that can be added to a ``View``. +The :mod:`mixins` module provides a set of reusable `mixin` +classes that can be added to a `View`. """ from django.contrib.auth.models import AnonymousUser @@ -41,7 +42,7 @@ __all__ = ( class RequestMixin(object): """ - Mixin class to provide request parsing behavior. + `Mixin` class to provide request parsing behavior. """ _USE_FORM_OVERLOADING = True @@ -52,7 +53,7 @@ class RequestMixin(object): """ The set of request parsers that the view can handle. - Should be a tuple/list of classes as described in the ``parsers`` module. + Should be a tuple/list of classes as described in the :mod:`parsers` module. """ parsers = () @@ -61,8 +62,8 @@ class RequestMixin(object): """ Returns the HTTP method. - This should be used instead of ``request.method``, as it allows the method - to be overridden by using a hidden form field on a form POST request. + This should be used instead of just reading :const:`request.method`, as it allows the `method` + to be overridden by using a hidden `form` field on a form POST request. """ if not hasattr(self, '_method'): self._load_method_and_content_type() @@ -100,7 +101,7 @@ class RequestMixin(object): def FILES(self): """ Parses the request body and returns the files. - Similar to request.FILES, except that it handles arbitrary parsers, + Similar to ``request.FILES``, except that it handles arbitrary parsers, and also works on methods other than POST (eg PUT). """ if not hasattr(self, '_files'): @@ -215,10 +216,10 @@ class RequestMixin(object): class ResponseMixin(object): """ - Adds behavior for pluggable Renderers to a :class:`.BaseView` or Django :class:`View`. class. + Adds behavior for pluggable `Renderers` to a :class:`views.BaseView` or Django :class:`View` class. Default behavior is to use standard HTTP Accept header content negotiation. - Also supports overriding the content type by specifying an _accept= parameter in the URL. + Also supports overriding the content type by specifying an ``_accept=`` parameter in the URL. Ignores Accept headers from Internet Explorer user agents and uses a sensible browser Accept header instead. """ @@ -228,7 +229,7 @@ class ResponseMixin(object): """ The set of response renderers that the view can handle. - Should be a tuple/list of classes as described in the ``renderers`` module. + Should be a tuple/list of classes as described in the :mod:`renderers` module. """ renderers = () @@ -237,7 +238,7 @@ class ResponseMixin(object): # out of the box with existing Django classes that use render_to_response. def render(self, response): """ - Takes a ``Response`` object and returns an ``HttpResponse``. + Takes a :obj:`Response` object and returns an :obj:`HttpResponse`. """ self.response = response @@ -354,21 +355,21 @@ class ResponseMixin(object): class AuthMixin(object): """ - Simple mixin class to add authentication and permission checking to a ``View`` class. + Simple :class:`mixin` class to add authentication and permission checking to a :class:`View` class. """ """ The set of authentication types that this view can handle. - Should be a tuple/list of classes as described in the ``authentication`` module. + Should be a tuple/list of classes as described in the :mod:`authentication` module. """ authentication = () """ The set of permissions that will be enforced on this view. - Should be a tuple/list of classes as described in the ``permissions`` module. + Should be a tuple/list of classes as described in the :mod:`permissions` module. """ permissions = () @@ -376,8 +377,8 @@ class AuthMixin(object): @property def user(self): """ - Returns the user for the current request, as determined by the set of - authentication classes applied to the ``View``. + Returns the :obj:`user` for the current request, as determined by the set of + :class:`authentication` classes applied to the :class:`View`. """ if not hasattr(self, '_user'): self._user = self._authenticate() @@ -413,12 +414,10 @@ class AuthMixin(object): class ResourceMixin(object): """ Provides request validation and response filtering behavior. - """ - """ - Should be a class as described in the ``resources`` module. + Should be a class as described in the :mod:`resources` module. - The ``resource`` is an object that maps a view onto it's representation on the server. + The :obj:`resource` is an object that maps a view onto it's representation on the server. It provides validation on the content of incoming requests, and filters the object representation into a serializable object for the response. @@ -436,8 +435,8 @@ class ResourceMixin(object): def validate_request(self, data, files): """ - Given the request data return the cleaned, validated content. - Typically raises a ErrorResponse with status code 400 (Bad Request) on failure. + Given the request *data* return the cleaned, validated content. + Typically raises an :class:`response.ErrorResponse` with status code 400 (Bad Request) on failure. """ resource = self.resource(self) return resource.validate_request(data, files) @@ -459,8 +458,8 @@ class ResourceMixin(object): class InstanceMixin(object): """ - Mixin class that is used to identify a view class as being the canonical identifier - for the resources it is mapped too. + `Mixin` class that is used to identify a `View` class as being the canonical identifier + for the resources it is mapped to. """ @classmethod @@ -482,7 +481,7 @@ class InstanceMixin(object): class ReadModelMixin(object): """ - Behavior to read a model instance on GET requests + Behavior to read a `model` instance on GET requests """ def get(self, request, *args, **kwargs): model = self.resource.model @@ -501,7 +500,7 @@ class ReadModelMixin(object): class CreateModelMixin(object): """ - Behavior to create a model instance on POST requests + Behavior to create a `model` instance on POST requests """ def post(self, request, *args, **kwargs): model = self.resource.model @@ -525,7 +524,7 @@ class CreateModelMixin(object): class UpdateModelMixin(object): """ - Behavior to update a model instance on PUT requests + Behavior to update a `model` instance on PUT requests """ def put(self, request, *args, **kwargs): model = self.resource.model @@ -550,7 +549,7 @@ class UpdateModelMixin(object): class DeleteModelMixin(object): """ - Behavior to delete a model instance on DELETE requests + Behavior to delete a `model` instance on DELETE requests """ def delete(self, request, *args, **kwargs): model = self.resource.model @@ -570,7 +569,7 @@ class DeleteModelMixin(object): class ListModelMixin(object): """ - Behavior to list a set of model instances on GET requests + Behavior to list a set of `model` instances on GET requests """ # NB. Not obvious to me if it would be better to set this on the resource? diff --git a/djangorestframework/parsers.py b/djangorestframework/parsers.py index 4337098af..06883ca35 100644 --- a/djangorestframework/parsers.py +++ b/djangorestframework/parsers.py @@ -5,8 +5,9 @@ to general HTTP requests. We need a method to be able to: -1) Determine the parsed content on a request for methods other than POST (eg typically also PUT) -2) Determine the parsed content on a request for media types other than application/x-www-form-urlencoded +1.) Determine the parsed content on a request for methods other than POST (eg typically also PUT) + +2.) Determine the parsed content on a request for media types other than application/x-www-form-urlencoded and multipart/form-data. (eg also handle multipart/json) """ @@ -22,47 +23,51 @@ __all__ = ( 'BaseParser', 'JSONParser', 'PlainTextParser', + 'DataFlatener', 'FormParser', - 'MultiPartParser' + 'MultiPartParser', ) class BaseParser(object): """ - All parsers should extend BaseParser, specifying a media_type attribute, - and overriding the parse() method. + All parsers should extend :class:`BaseParser`, specifying a :attr:`media_type` attribute, + and overriding the :meth:`parse` method. """ media_type = None def __init__(self, view): """ Initialize the parser with the ``View`` instance as state, - in case the parser needs to access any metadata on the ``View`` object. + in case the parser needs to access any metadata on the :obj:`View` object. """ self.view = view def can_handle_request(self, content_type): """ - Returns `True` if this parser is able to deal with the given media type. + Returns :const:`True` if this parser is able to deal with the given *content_type*. - The default implementation for this function is to check the ``media_type`` - argument against the ``media_type`` attribute set on the class to see if + The default implementation for this function is to check the *content_type* + argument against the :attr:`media_type` attribute set on the class to see if they match. This may be overridden to provide for other behavior, but typically you'll - instead want to just set the ``media_type`` attribute on the class. + instead want to just set the :attr:`media_type` attribute on the class. """ return media_type_matches(content_type, self.media_type) def parse(self, stream): """ - Given a stream to read from, return the deserialized output. + Given a *stream* to read from, return the deserialized output. Should return a 2-tuple of (data, files). """ raise NotImplementedError("BaseParser.parse() Must be overridden to be implemented.") class JSONParser(BaseParser): + """ + Parses JSON-serialized data. + """ media_type = 'application/json' def parse(self, stream): @@ -74,11 +79,14 @@ class JSONParser(BaseParser): class DataFlatener(object): - """Utility object for flattening dictionaries of lists. Useful for "urlencoded" decoded data.""" - + """ + Utility object for flattening dictionaries of lists. Useful for "urlencoded" decoded data. + """ + # TODO: move me to utils ?? + def flatten_data(self, data): - """Given a data dictionary {: }, returns a flattened dictionary - with information provided by the method "is_a_list".""" + """Given a *data* dictionary ``{: }``, returns a flattened dictionary + with information provided by the method :meth:`is_a_list`.""" flatdata = dict() for key, val_list in data.items(): if self.is_a_list(key, val_list): @@ -93,15 +101,13 @@ class DataFlatener(object): return flatdata def is_a_list(self, key, val_list): - """Returns True if the parameter with name *key* is expected to be a list, or False otherwise. + """Returns :const:`True` if the parameter with name *key* is expected to be a list, or :const:`False` otherwise. *val_list* which is the received value for parameter *key* can be used to guess the answer.""" return False class PlainTextParser(BaseParser): """ - Plain text parser. - Simply returns the content of the stream. """ media_type = 'text/plain' @@ -113,10 +119,10 @@ class PlainTextParser(BaseParser): class FormParser(BaseParser, DataFlatener): """ The default parser for form data. - Return a dict containing a single value for each non-reserved parameter. + Returns a dict containing a single value for each non-reserved parameter. In order to handle select multiple (and having possibly more than a single value for each parameter), - you can customize the output by subclassing the method 'is_a_list'.""" + you can customize the output by subclassing the method :meth:`DataFlatener.is_a_list`.""" media_type = 'application/x-www-form-urlencoded' diff --git a/djangorestframework/permissions.py b/djangorestframework/permissions.py index 1b1515581..10ba7eace 100644 --- a/djangorestframework/permissions.py +++ b/djangorestframework/permissions.py @@ -36,7 +36,7 @@ class BasePermission(object): def check_permission(self, auth): """ - Should simply return, or raise an ErrorResponse. + Should simply return, or raise an :class:`response.ErrorResponse`. """ pass @@ -59,7 +59,7 @@ class IsAuthenticated(BasePermission): if not user.is_authenticated(): raise _403_FORBIDDEN_RESPONSE -class IsAdminUser(): +class IsAdminUser(BasePermission): """ Allows access only to admin users. """ @@ -85,7 +85,7 @@ class PerUserThrottling(BasePermission): """ Rate throttling of requests on a per-user basis. - The rate is set by a 'throttle' attribute on the ``View`` class. + The rate (requests / seconds) is set by a :attr:`throttle` attribute on the ``View`` class. The attribute is a two tuple of the form (number of requests, duration in seconds). The user id will be used as a unique identifier if the user is authenticated. diff --git a/djangorestframework/renderers.py b/djangorestframework/renderers.py index 112736d2f..3e59511c3 100644 --- a/djangorestframework/renderers.py +++ b/djangorestframework/renderers.py @@ -36,8 +36,8 @@ __all__ = ( class BaseRenderer(object): """ - All renderers must extend this class, set the media_type attribute, - and override the render() function. + All renderers must extend this class, set the :attr:`media_type` attribute, + and override the :meth:`render` method. """ media_type = None @@ -51,7 +51,7 @@ class BaseRenderer(object): The requested media type is also passed to this method, as it may contain parameters relevant to how the parser should render the output. - EG: 'application/json; indent=4' + EG: ``application/json; indent=4`` By default render simply returns the output as-is. Override this method to provide for other behavior. @@ -102,8 +102,8 @@ class TemplateRenderer(BaseRenderer): A Base class provided for convenience. Render the object simply by using the given template. - To create a template renderer, subclass this, and set - the ``media_type`` and ``template`` attributes + To create a template renderer, subclass this class, and set + the :attr:`media_type` and `:attr:template` attributes. """ media_type = None template = None From 8c3280f9c0d73c4e2536f1d757ad457b4a8f1de7 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 19 May 2011 08:36:55 +0100 Subject: [PATCH 58/70] data flattening needs to go into resource --- djangorestframework/mixins.py | 3 +- djangorestframework/parsers.py | 109 ++++------- djangorestframework/permissions.py | 1 + djangorestframework/tests/content.py | 2 +- djangorestframework/tests/parsers.py | 264 +++++++++++++------------- examples/modelresourceexample/urls.py | 2 +- 6 files changed, 171 insertions(+), 210 deletions(-) diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index e101b7883..3b2f72424 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -26,7 +26,7 @@ __all__ = ( 'ResponseMixin', 'AuthMixin', 'ResourceMixin', - # + # Reverse URL lookup behavior 'InstanceMixin', # Model behavior mixins 'ReadModelMixin', @@ -360,7 +360,6 @@ class AuthMixin(object): """ The set of authentication types that this view can handle. - Should be a tuple/list of classes as described in the ``authentication`` module. """ authentication = () diff --git a/djangorestframework/parsers.py b/djangorestframework/parsers.py index 4337098af..2fd1e15df 100644 --- a/djangorestframework/parsers.py +++ b/djangorestframework/parsers.py @@ -63,9 +63,18 @@ class BaseParser(object): class JSONParser(BaseParser): + """ + JSON parser. + """ media_type = 'application/json' def parse(self, stream): + """ + Returns a 2-tuple of `(data, files)`. + + `data` will be an object which is the parsed content of the response. + `files` will always be `None`. + """ try: return (json.load(stream), None) except ValueError, exc: @@ -73,103 +82,55 @@ class JSONParser(BaseParser): {'detail': 'JSON parse error - %s' % unicode(exc)}) -class DataFlatener(object): - """Utility object for flattening dictionaries of lists. Useful for "urlencoded" decoded data.""" - - def flatten_data(self, data): - """Given a data dictionary {: }, returns a flattened dictionary - with information provided by the method "is_a_list".""" - flatdata = dict() - for key, val_list in data.items(): - if self.is_a_list(key, val_list): - flatdata[key] = val_list - else: - if val_list: - flatdata[key] = val_list[0] - else: - # If the list is empty, but the parameter is not a list, - # we strip this parameter. - data.pop(key) - return flatdata - - def is_a_list(self, key, val_list): - """Returns True if the parameter with name *key* is expected to be a list, or False otherwise. - *val_list* which is the received value for parameter *key* can be used to guess the answer.""" - return False - - class PlainTextParser(BaseParser): """ Plain text parser. - - Simply returns the content of the stream. """ media_type = 'text/plain' def parse(self, stream): + """ + Returns a 2-tuple of `(data, files)`. + + `data` will simply be a string representing the body of the request. + `files` will always be `None`. + """ return (stream.read(), None) -class FormParser(BaseParser, DataFlatener): +class FormParser(BaseParser): + """ + Parser for form data. """ - The default parser for form data. - Return a dict containing a single value for each non-reserved parameter. - - In order to handle select multiple (and having possibly more than a single value for each parameter), - you can customize the output by subclassing the method 'is_a_list'.""" media_type = 'application/x-www-form-urlencoded' - """The value of the parameter when the select multiple is empty. - Browsers are usually stripping the select multiple that have no option selected from the parameters sent. - A common hack to avoid this is to send the parameter with a value specifying that the list is empty. - This value will always be stripped before the data is returned. - """ - EMPTY_VALUE = '_empty' - RESERVED_FORM_PARAMS = ('csrfmiddlewaretoken',) - def parse(self, stream): + """ + Returns a 2-tuple of `(data, files)`. + + `data` will be a `QueryDict` containing all the form parameters. + `files` will always be `None`. + """ data = parse_qs(stream.read(), keep_blank_values=True) - - # removing EMPTY_VALUEs from the lists and flatening the data - for key, val_list in data.items(): - self.remove_empty_val(val_list) - data = self.flatten_data(data) - - # Strip any parameters that we are treating as reserved - for key in data.keys(): - if key in self.RESERVED_FORM_PARAMS: - data.pop(key) - return (data, None) - def remove_empty_val(self, val_list): - """ """ - while(1): # Because there might be several times EMPTY_VALUE in the list - try: - ind = val_list.index(self.EMPTY_VALUE) - except ValueError: - break - else: - val_list.pop(ind) +class MultiPartParser(BaseParser): + """ + Parser for multipart form data, which may include file data. + """ -class MultiPartParser(BaseParser, DataFlatener): media_type = 'multipart/form-data' - RESERVED_FORM_PARAMS = ('csrfmiddlewaretoken',) def parse(self, stream): + """ + Returns a 2-tuple of `(data, files)`. + + `data` will be a `QueryDict` containing all the form parameters. + `files` will be a `QueryDict` containing all the form files. + """ upload_handlers = self.view.request._get_upload_handlers() django_parser = DjangoMultiPartParser(self.view.request.META, stream, upload_handlers) - data, files = django_parser.parse() + return django_parser.parse() - # Flatening data, files and combining them - data = self.flatten_data(dict(data.iterlists())) - files = self.flatten_data(dict(files.iterlists())) - - # Strip any parameters that we are treating as reserved - for key in data.keys(): - if key in self.RESERVED_FORM_PARAMS: - data.pop(key) - - return (data, files) diff --git a/djangorestframework/permissions.py b/djangorestframework/permissions.py index 1b1515581..ae550f252 100644 --- a/djangorestframework/permissions.py +++ b/djangorestframework/permissions.py @@ -59,6 +59,7 @@ class IsAuthenticated(BasePermission): if not user.is_authenticated(): raise _403_FORBIDDEN_RESPONSE + class IsAdminUser(): """ Allows access only to admin users. diff --git a/djangorestframework/tests/content.py b/djangorestframework/tests/content.py index fb7a2b14e..ee3597a4b 100644 --- a/djangorestframework/tests/content.py +++ b/djangorestframework/tests/content.py @@ -36,7 +36,7 @@ class TestContentParsing(TestCase): form_data = {'qwerty': 'uiop'} view.parsers = (FormParser, MultiPartParser) view.request = self.req.put('/', data=form_data) - self.assertEqual(view.DATA, form_data) + self.assertEqual(view.DATA.items(), form_data.items()) def ensure_determines_non_form_content_PUT(self, view): """Ensure view.RAW_CONTENT returns content for PUT request with non-form content.""" diff --git a/djangorestframework/tests/parsers.py b/djangorestframework/tests/parsers.py index 2720f4c77..764a8f5cb 100644 --- a/djangorestframework/tests/parsers.py +++ b/djangorestframework/tests/parsers.py @@ -1,133 +1,133 @@ -""" -.. - >>> from djangorestframework.parsers import FormParser - >>> from djangorestframework.compat import RequestFactory - >>> from djangorestframework.views import BaseView - >>> from StringIO import StringIO - >>> from urllib import urlencode - >>> req = RequestFactory().get('/') - >>> some_view = BaseView() - >>> some_view.request = req # Make as if this request had been dispatched - -FormParser -============ - -Data flatening ----------------- - -Here is some example data, which would eventually be sent along with a post request : - - >>> inpt = urlencode([ - ... ('key1', 'bla1'), - ... ('key2', 'blo1'), ('key2', 'blo2'), - ... ]) - -Default behaviour for :class:`parsers.FormParser`, is to return a single value for each parameter : - - >>> (data, files) = FormParser(some_view).parse(StringIO(inpt)) - >>> data == {'key1': 'bla1', 'key2': 'blo1'} - True - -However, you can customize this behaviour by subclassing :class:`parsers.FormParser`, and overriding :meth:`parsers.FormParser.is_a_list` : - - >>> class MyFormParser(FormParser): - ... - ... def is_a_list(self, key, val_list): - ... return len(val_list) > 1 - -This new parser only flattens the lists of parameters that contain a single value. - - >>> (data, files) = MyFormParser(some_view).parse(StringIO(inpt)) - >>> data == {'key1': 'bla1', 'key2': ['blo1', 'blo2']} - True - -.. note:: The same functionality is available for :class:`parsers.MultiPartParser`. - -Submitting an empty list --------------------------- - -When submitting an empty select multiple, like this one :: - - - -The browsers usually strip the parameter completely. A hack to avoid this, and therefore being able to submit an empty select multiple, is to submit a value that tells the server that the list is empty :: - - - -:class:`parsers.FormParser` provides the server-side implementation for this hack. Considering the following posted data : - - >>> inpt = urlencode([ - ... ('key1', 'blo1'), ('key1', '_empty'), - ... ('key2', '_empty'), - ... ]) - -:class:`parsers.FormParser` strips the values ``_empty`` from all the lists. - - >>> (data, files) = MyFormParser(some_view).parse(StringIO(inpt)) - >>> data == {'key1': 'blo1'} - True - -Oh ... but wait a second, the parameter ``key2`` isn't even supposed to be a list, so the parser just stripped it. - - >>> class MyFormParser(FormParser): - ... - ... def is_a_list(self, key, val_list): - ... return key == 'key2' - ... - >>> (data, files) = MyFormParser(some_view).parse(StringIO(inpt)) - >>> data == {'key1': 'blo1', 'key2': []} - True - -Better like that. Note that you can configure something else than ``_empty`` for the empty value by setting :attr:`parsers.FormParser.EMPTY_VALUE`. -""" -import httplib, mimetypes -from tempfile import TemporaryFile -from django.test import TestCase -from djangorestframework.compat import RequestFactory -from djangorestframework.parsers import MultiPartParser -from djangorestframework.views import BaseView -from StringIO import StringIO - -def encode_multipart_formdata(fields, files): - """For testing multipart parser. - fields is a sequence of (name, value) elements for regular form fields. - files is a sequence of (name, filename, value) elements for data to be uploaded as files - Return (content_type, body).""" - BOUNDARY = '----------ThIs_Is_tHe_bouNdaRY_$' - CRLF = '\r\n' - L = [] - for (key, value) in fields: - L.append('--' + BOUNDARY) - L.append('Content-Disposition: form-data; name="%s"' % key) - L.append('') - L.append(value) - for (key, filename, value) in files: - L.append('--' + BOUNDARY) - L.append('Content-Disposition: form-data; name="%s"; filename="%s"' % (key, filename)) - L.append('Content-Type: %s' % get_content_type(filename)) - L.append('') - L.append(value) - L.append('--' + BOUNDARY + '--') - L.append('') - body = CRLF.join(L) - content_type = 'multipart/form-data; boundary=%s' % BOUNDARY - return content_type, body - -def get_content_type(filename): - return mimetypes.guess_type(filename)[0] or 'application/octet-stream' - -class TestMultiPartParser(TestCase): - def setUp(self): - self.req = RequestFactory() - self.content_type, self.body = encode_multipart_formdata([('key1', 'val1'), ('key1', 'val2')], - [('file1', 'pic.jpg', 'blablabla'), ('file1', 't.txt', 'blobloblo')]) - - def test_multipartparser(self): - """Ensure that MultiPartParser can parse multipart/form-data that contains a mix of several files and parameters.""" - post_req = RequestFactory().post('/', self.body, content_type=self.content_type) - view = BaseView() - view.request = post_req - (data, files) = MultiPartParser(view).parse(StringIO(self.body)) - self.assertEqual(data['key1'], 'val1') - self.assertEqual(files['file1'].read(), 'blablabla') +# """ +# .. +# >>> from djangorestframework.parsers import FormParser +# >>> from djangorestframework.compat import RequestFactory +# >>> from djangorestframework.views import BaseView +# >>> from StringIO import StringIO +# >>> from urllib import urlencode +# >>> req = RequestFactory().get('/') +# >>> some_view = BaseView() +# >>> some_view.request = req # Make as if this request had been dispatched +# +# FormParser +# ============ +# +# Data flatening +# ---------------- +# +# Here is some example data, which would eventually be sent along with a post request : +# +# >>> inpt = urlencode([ +# ... ('key1', 'bla1'), +# ... ('key2', 'blo1'), ('key2', 'blo2'), +# ... ]) +# +# Default behaviour for :class:`parsers.FormParser`, is to return a single value for each parameter : +# +# >>> (data, files) = FormParser(some_view).parse(StringIO(inpt)) +# >>> data == {'key1': 'bla1', 'key2': 'blo1'} +# True +# +# However, you can customize this behaviour by subclassing :class:`parsers.FormParser`, and overriding :meth:`parsers.FormParser.is_a_list` : +# +# >>> class MyFormParser(FormParser): +# ... +# ... def is_a_list(self, key, val_list): +# ... return len(val_list) > 1 +# +# This new parser only flattens the lists of parameters that contain a single value. +# +# >>> (data, files) = MyFormParser(some_view).parse(StringIO(inpt)) +# >>> data == {'key1': 'bla1', 'key2': ['blo1', 'blo2']} +# True +# +# .. note:: The same functionality is available for :class:`parsers.MultiPartParser`. +# +# Submitting an empty list +# -------------------------- +# +# When submitting an empty select multiple, like this one :: +# +# +# +# The browsers usually strip the parameter completely. A hack to avoid this, and therefore being able to submit an empty select multiple, is to submit a value that tells the server that the list is empty :: +# +# +# +# :class:`parsers.FormParser` provides the server-side implementation for this hack. Considering the following posted data : +# +# >>> inpt = urlencode([ +# ... ('key1', 'blo1'), ('key1', '_empty'), +# ... ('key2', '_empty'), +# ... ]) +# +# :class:`parsers.FormParser` strips the values ``_empty`` from all the lists. +# +# >>> (data, files) = MyFormParser(some_view).parse(StringIO(inpt)) +# >>> data == {'key1': 'blo1'} +# True +# +# Oh ... but wait a second, the parameter ``key2`` isn't even supposed to be a list, so the parser just stripped it. +# +# >>> class MyFormParser(FormParser): +# ... +# ... def is_a_list(self, key, val_list): +# ... return key == 'key2' +# ... +# >>> (data, files) = MyFormParser(some_view).parse(StringIO(inpt)) +# >>> data == {'key1': 'blo1', 'key2': []} +# True +# +# Better like that. Note that you can configure something else than ``_empty`` for the empty value by setting :attr:`parsers.FormParser.EMPTY_VALUE`. +# """ +# import httplib, mimetypes +# from tempfile import TemporaryFile +# from django.test import TestCase +# from djangorestframework.compat import RequestFactory +# from djangorestframework.parsers import MultiPartParser +# from djangorestframework.views import BaseView +# from StringIO import StringIO +# +# def encode_multipart_formdata(fields, files): +# """For testing multipart parser. +# fields is a sequence of (name, value) elements for regular form fields. +# files is a sequence of (name, filename, value) elements for data to be uploaded as files +# Return (content_type, body).""" +# BOUNDARY = '----------ThIs_Is_tHe_bouNdaRY_$' +# CRLF = '\r\n' +# L = [] +# for (key, value) in fields: +# L.append('--' + BOUNDARY) +# L.append('Content-Disposition: form-data; name="%s"' % key) +# L.append('') +# L.append(value) +# for (key, filename, value) in files: +# L.append('--' + BOUNDARY) +# L.append('Content-Disposition: form-data; name="%s"; filename="%s"' % (key, filename)) +# L.append('Content-Type: %s' % get_content_type(filename)) +# L.append('') +# L.append(value) +# L.append('--' + BOUNDARY + '--') +# L.append('') +# body = CRLF.join(L) +# content_type = 'multipart/form-data; boundary=%s' % BOUNDARY +# return content_type, body +# +# def get_content_type(filename): +# return mimetypes.guess_type(filename)[0] or 'application/octet-stream' +# +#class TestMultiPartParser(TestCase): +# def setUp(self): +# self.req = RequestFactory() +# self.content_type, self.body = encode_multipart_formdata([('key1', 'val1'), ('key1', 'val2')], +# [('file1', 'pic.jpg', 'blablabla'), ('file1', 't.txt', 'blobloblo')]) +# +# def test_multipartparser(self): +# """Ensure that MultiPartParser can parse multipart/form-data that contains a mix of several files and parameters.""" +# post_req = RequestFactory().post('/', self.body, content_type=self.content_type) +# view = BaseView() +# view.request = post_req +# (data, files) = MultiPartParser(view).parse(StringIO(self.body)) +# self.assertEqual(data['key1'], 'val1') +# self.assertEqual(files['file1'].read(), 'blablabla') diff --git a/examples/modelresourceexample/urls.py b/examples/modelresourceexample/urls.py index 5860c807f..bb71ddd37 100644 --- a/examples/modelresourceexample/urls.py +++ b/examples/modelresourceexample/urls.py @@ -8,7 +8,7 @@ class MyModelResource(ModelResource): fields = ('foo', 'bar', 'baz', 'url') ordering = ('created',) -urlpatterns = patterns('modelresourceexample.views', +urlpatterns = patterns('', url(r'^$', ListOrCreateModelView.as_view(resource=MyModelResource), name='model-resource-root'), url(r'^([0-9]+)/$', InstanceModelView.as_view(resource=MyModelResource)), ) From 82c4ca96126cfedd4a8471452d956e8bb432ba5b Mon Sep 17 00:00:00 2001 From: markotibold Date: Thu, 19 May 2011 19:36:30 +0200 Subject: [PATCH 59/70] The core is now documented from the docstrings in the source. --- djangorestframework/compat.py | 4 +- djangorestframework/permissions.py | 8 +- djangorestframework/resources.py | 54 ++++++------ djangorestframework/response.py | 7 ++ djangorestframework/views.py | 19 ++-- docs/library/resource.rst | 135 +---------------------------- 6 files changed, 58 insertions(+), 169 deletions(-) diff --git a/djangorestframework/compat.py b/djangorestframework/compat.py index 45a695c8c..0274511ab 100644 --- a/djangorestframework/compat.py +++ b/djangorestframework/compat.py @@ -1,5 +1,5 @@ """ -Compatability module to provide support for backwards compatability with older versions of django/python +The :mod:`compatability` module provides support for backwards compatability with older versions of django/python. """ # cStringIO only if it's available @@ -42,7 +42,7 @@ except ImportError: __ http://www.djangoproject.com/documentation/testing/#the-test-client - Once you have a :obj:`request` object you can pass it to any :func:`view` function, + Once you have a `request` object you can pass it to any :func:`view` function, just as if that :func:`view` had been hooked up using a URLconf. """ def request(self, **request): diff --git a/djangorestframework/permissions.py b/djangorestframework/permissions.py index 3831424f7..1f6151f8c 100644 --- a/djangorestframework/permissions.py +++ b/djangorestframework/permissions.py @@ -1,3 +1,9 @@ +""" +The :mod:`permissions` module bundles a set of permission classes that are used +for checking if a request passes a certain set of constraints. You can assign a permision +class to your view by setting your View's :attr:`permissions` class attribute. +""" + from django.core.cache import cache from djangorestframework import status from djangorestframework.response import ErrorResponse @@ -36,7 +42,7 @@ class BasePermission(object): def check_permission(self, auth): """ - Should simply return, or raise an :class:`response.ErrorResponse`. + Should simply return, or raise an :exc:`response.ErrorResponse`. """ pass diff --git a/djangorestframework/resources.py b/djangorestframework/resources.py index fa3dd1b7e..27d25ba9d 100644 --- a/djangorestframework/resources.py +++ b/djangorestframework/resources.py @@ -124,8 +124,8 @@ class BaseResource(object): def validate_request(self, data, files): """ - Given the request data return the cleaned, validated content. - Typically raises a ErrorResponse with status code 400 (Bad Request) on failure. + Given the request content return the cleaned, validated content. + Typically raises a :exc:`response.ErrorResponse` with status code 400 (Bad Request) on failure. """ return data @@ -160,28 +160,28 @@ class Resource(BaseResource): class FormResource(Resource): """ Resource class that uses forms for validation. - Also provides a get_bound_form() method which may be used by some renderers. + Also provides a :meth:`get_bound_form` method which may be used by some renderers. - On calling validate() this validator may set a `.bound_form_instance` attribute on the + On calling :meth:`validate_request` this validator may set a :attr:`bound_form_instance` attribute on the view, which may be used by some renderers. """ - - """ - The form class that should be used for request validation. - """ form = None + """ + The :class:`Form` class that should be used for request validation. + """ + def validate_request(self, data, files): """ Given some content as input return some cleaned, validated content. - Raises a ErrorResponse with status code 400 (Bad Request) on failure. + Raises a :exc:`response.ErrorResponse` 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. + Validation is standard form validation, with an additional constraint that *no extra unknown fields* may be supplied. - On failure the ErrorResponse 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}. + On failure the :exc:`response.ErrorResponse` content is a dict which may contain :obj:`'errors'` and :obj:`'field-errors'` keys. + If the :obj:`'errors'` key exists it is a list of strings of non-field errors. + If the :obj:`'field-errors'` key exists it is a dict of ``{'field name as string': ['errors as strings', ...]}``. """ return self._validate(data, files) @@ -277,7 +277,7 @@ class FormResource(Resource): def get_bound_form(self, data=None, files=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 form validation is turned off (:attr:`form` class attribute is :const:`None`) then returns :const:`None`. """ if not self.form: return None @@ -305,7 +305,7 @@ class FormResource(Resource): class ModelResource(FormResource): """ Resource class that uses forms for validation and otherwise falls back to a model form if no form is set. - Also provides a get_bound_form() method which may be used by some renderers. + Also provides a :meth:`get_bound_form` method which may be used by some renderers. """ # Auto-register new ModelResource classes into _model_to_resource @@ -313,7 +313,7 @@ class ModelResource(FormResource): """ The form class that should be used for request validation. - If set to ``None`` then the default model form validation will be used. + If set to :const:`None` then the default model form validation will be used. """ form = None @@ -330,18 +330,18 @@ class ModelResource(FormResource): The name of a model field. The name of an attribute on the model. The name of an attribute on the resource. - The name of an method on the model, with a signature like ``func(self)``. - The name of an method on the resource, with a signature like ``func(self, instance)``. + The name of a method on the model, with a signature like ``func(self)``. + The name of a method on the resource, with a signature like ``func(self, instance)``. """ fields = None """ - The list of fields to exclude. This is only used if ``fields`` is not set. + The list of fields to exclude. This is only used if :attr:`fields` is not set. """ exclude = ('id', 'pk') """ - The list of extra fields to include. This is only used if ``fields`` is not set. + The list of extra fields to include. This is only used if :attr:`fields` is not set. """ include = ('url',) @@ -349,16 +349,16 @@ class ModelResource(FormResource): def validate_request(self, data, files): """ Given some content as input return some cleaned, validated content. - Raises a ErrorResponse with status code 400 (Bad Request) on failure. + Raises a :exc:`response.ErrorResponse` 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 ErrorResponse 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}. + On failure the ErrorResponse content is a dict which may contain :obj:`'errors'` and :obj:`'field-errors'` keys. + If the :obj:`'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(data, files, allowed_extra_fields=self._property_fields_set) @@ -367,7 +367,7 @@ class ModelResource(FormResource): """ Given some content return a ``Form`` instance bound to that content. - If the form class attribute has been explicitly set then that class will be used + If the :attr:`form` class attribute has been explicitly set then that class will be used to create the Form, otherwise the model will be used to create a ModelForm. """ @@ -396,9 +396,9 @@ class ModelResource(FormResource): def url(self, instance): """ - Attempts to reverse resolve the url of the given model instance for this resource. + Attempts to reverse resolve the url of the given model *instance* for this resource. - Requires a ``View`` with ``InstanceMixin`` to have been created for this resource. + Requires a ``View`` with :class:`mixins.InstanceMixin` to have been created for this resource. This method can be overridden if you need to set the resource url reversing explicitly. """ diff --git a/djangorestframework/response.py b/djangorestframework/response.py index 72bc16c8b..f6bbe3be3 100644 --- a/djangorestframework/response.py +++ b/djangorestframework/response.py @@ -1,3 +1,10 @@ +""" +The :mod:`response` module provides Response classes you can use in your +views to return a certain HTTP response. Typically a response is *rendered* +into a HTTP response depending on what renderers are set on your view and +als depending on the accept header of the request. +""" + from django.core.handlers.wsgi import STATUS_CODE_TEXT __all__ = ('Response', 'ErrorResponse') diff --git a/djangorestframework/views.py b/djangorestframework/views.py index 81567e687..626dc696f 100644 --- a/djangorestframework/views.py +++ b/djangorestframework/views.py @@ -1,3 +1,10 @@ +""" +The :mod:`views` module provides the Views you will most probably +be subclassing in your implementation. + +By setting or modifying class attributes on your view, you change it's predefined behaviour. +""" + from django.core.urlresolvers import set_script_prefix from django.views.decorators.csrf import csrf_exempt @@ -26,25 +33,25 @@ class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View): # Use the base resource by default resource = resources.Resource - # List of renderers the resource can serialize the response with, ordered by preference. renderers = ( renderers.JSONRenderer, renderers.DocumentingHTMLRenderer, renderers.DocumentingXHTMLRenderer, renderers.DocumentingPlainTextRenderer, renderers.XMLRenderer ) - - # List of parsers the resource can parse the request with. + """ List of renderers the resource can serialize the response with, ordered by preference.""" + parsers = ( parsers.JSONParser, parsers.FormParser, parsers.MultiPartParser ) + """ List of parsers the resource can parse the request with.""" - # List of all authenticating methods to attempt. authentication = ( authentication.UserLoggedInAuthenticaton, authentication.BasicAuthenticaton ) + """ List of all authenticating methods to attempt.""" - # List of all permissions that must be checked. permissions = ( permissions.FullAnonAccess, ) - + """ List of all permissions that must be checked.""" + # 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 renderers. diff --git a/docs/library/resource.rst b/docs/library/resource.rst index b6cf028e2..2a95051bf 100644 --- a/docs/library/resource.rst +++ b/docs/library/resource.rst @@ -1,136 +1,5 @@ :mod:`resource` =============== -.. module:: resource - -The :mod:`resource` module is the core of Django REST framework. It provides the :class:`Resource` base class which handles incoming HTTP requests and maps them to method calls, performing authentication, input deserialization, input validation and output serialization. - -Resources are created by sublassing :class:`Resource`, setting a number of class attributes, and overriding one or more methods. - -.. class:: Resource - -:class:`Resource` class attributes ----------------------------------- - -The following class attributes determine the behavior of the Resource and are intended to be overridden. - -.. attribute:: Resource.allowed_methods - - A list of the HTTP methods that the Resource supports. - HTTP requests to the resource that do not map to an allowed operation will result in a 405 Method Not Allowed response. - - Default: ``('GET',)`` - -.. attribute:: Resource.anon_allowed_methods - - A list of the HTTP methods that the Resource supports for unauthenticated users. - Unauthenticated HTTP requests to the resource that do not map to an allowed operation will result in a 405 Method Not Allowed response. - - Default: ``()`` - -.. attribute:: Resource.emitters - - The list of emitters that the Resource supports. This determines which media types the resource can serialize it's output to. Clients can specify which media types they accept using standard HTTP content negotiation via the Accept header. (See `RFC 2616 - Sec 14.1 `_) Clients can also override this standard content negotiation by specifying a `_format` ... - - The :mod:`emitters` module provides the :class:`BaseEmitter` class and a set of default emitters, including emitters for JSON and XML, as well as emitters for HTML and Plain Text which provide for a self documenting API. - - The ordering of the Emitters is important as it determines an order of preference. - - Default: ``(emitters.JSONEmitter, emitters.DocumentingHTMLEmitter, emitters.DocumentingXHTMLEmitter, emitters.DocumentingPlainTextEmitter, emitters.XMLEmitter)`` - -.. attribute:: Resource.parsers - - The list of parsers that the Resource supports. This determines which media types the resource can accept as input for incoming HTTP requests. (Typically PUT and POST requests). - - The ordering of the Parsers may be considered informative of preference but is not used ... - - Default: ``(parsers.JSONParser, parsers.XMLParser, parsers.FormParser)`` - -.. attribute:: Resource.authenticators - - The list of authenticators that the Resource supports. This determines which authentication methods (eg Basic, Digest, OAuth) are used to authenticate requests. - - Default: ``(authenticators.UserLoggedInAuthenticator, authenticators.BasicAuthenticator)`` - -.. attribute:: Resource.form - - If not None, this attribute should be a Django form which will be used to validate any request data. - This attribute is typically only used for POST or PUT requests to the resource. - - Deafult: ``None`` - -.. attribute:: Resource.callmap - - Maps HTTP methods to function calls on the :class:`Resource`. It may be overridden in order to add support for other HTTP methods such as HEAD, OPTIONS and PATCH, or in order to map methods to different function names, for example to use a more `CRUD `_ like style. - - Default: ``{ 'GET': 'get', 'POST': 'post', 'PUT': 'put', 'DELETE': 'delete' }`` - - -:class:`Resource` methods -------------------------- - -.. method:: Resource.get -.. method:: Resource.post -.. method:: Resource.put -.. method:: Resource.delete -.. method:: Resource.authenticate -.. method:: Resource.reverse - -:class:`Resource` properties ----------------------------- - -.. method:: Resource.name -.. method:: Resource.description -.. method:: Resource.default_emitter -.. method:: Resource.default_parser -.. method:: Resource.emitted_media_types -.. method:: Resource.parsed_media_types - -:class:`Resource` reserved form and query parameters ----------------------------------------------------- - -.. attribute:: Resource.ACCEPT_QUERY_PARAM - - If set, allows the default `Accept:` header content negotiation to be bypassed by setting the requested media type in a query parameter on the URL. This can be useful if it is necessary to be able to hyperlink to a given format on the Resource using standard HTML. - - Set to None to disable, or to another string value to use another name for the reserved URL query parameter. - - Default: ``"_accept"`` - -.. attribute:: Resource.METHOD_PARAM - - If set, allows for PUT and DELETE requests to be tunneled on form POST operations, by setting a (typically hidden) form field with the method name. This allows standard HTML forms to perform method requests which would otherwise `not be supported `_ - - Set to None to disable, or to another string value to use another name for the reserved form field. - - Default: ``"_method"`` - -.. attribute:: Resource.CONTENTTYPE_PARAM - - Used together with :attr:`CONTENT_PARAM`. - - If set, allows for arbitrary content types to be tunneled on form POST operations, by setting a form field with the content type. This allows standard HTML forms to perform requests with content types other those `supported by default `_ (ie. `application/x-www-form-urlencoded`, `multipart/form-data`, and `text-plain`) - - Set to None to disable, or to another string value to use another name for the reserved form field. - - Default: ``"_contenttype"`` - -.. attribute:: Resource.CONTENT_PARAM - - Used together with :attr:`CONTENTTYPE_PARAM`. - - Set to None to disable, or to another string value to use another name for the reserved form field. - - Default: ``"_content"`` - -.. attribute:: Resource.CSRF_PARAM - - The name used in Django's (typically hidden) form field for `CSRF Protection `_. - - Setting to None does not disable Django's CSRF middleware, but it does mean that the field name will not be treated as reserved by FlyWheel, so for example the default :class:`FormParser` will return fields with this as part of the request content, rather than ignoring them. - - Default:: ``"csrfmiddlewaretoken"`` - -reserved params -internal methods - +.. automodule:: resources + :members: From c53175914752502629141556f3e001e9d2e9f7fa Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 23 May 2011 17:07:31 +0100 Subject: [PATCH 60/70] name and description --- .hgignore | 1 + djangorestframework/mixins.py | 5 +- djangorestframework/tests/description.py | 42 ++++++----- djangorestframework/utils/description.py | 94 +++++++++++++++++++----- djangorestframework/views.py | 22 ++++-- examples/blogpost/urls.py | 10 ++- 6 files changed, 126 insertions(+), 48 deletions(-) diff --git a/.hgignore b/.hgignore index 5e9c03980..aa541d5c9 100644 --- a/.hgignore +++ b/.hgignore @@ -2,6 +2,7 @@ syntax: glob *.pyc *.db +assetplatform.egg-info/* *~ coverage.xml env diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 73ffbec26..622575923 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -467,12 +467,13 @@ class InstanceMixin(object): Store the callable object on the resource class that has been associated with this view. """ view = super(InstanceMixin, cls).as_view(**initkwargs) - if 'resource' in initkwargs: + resource = getattr(cls(**initkwargs), 'resource', None) + if resource: # We do a little dance when we store the view callable... # we need to store it wrapped in a 1-tuple, so that inspect will treat it # as a function when we later look it up (rather than turning it into a method). # This makes sure our URL reversing works ok. - initkwargs['resource'].view_callable = (view,) + resource.view_callable = (view,) return view diff --git a/djangorestframework/tests/description.py b/djangorestframework/tests/description.py index d5a1102f7..6c494efaa 100644 --- a/djangorestframework/tests/description.py +++ b/djangorestframework/tests/description.py @@ -37,14 +37,15 @@ class TestViewNamesAndDescriptions(TestCase): """Ensure Resource names are based on the classname by default.""" class MockView(BaseView): pass - self.assertEquals(get_name(MockView()), 'Mock View') + self.assertEquals(get_name(MockView()), 'Mock') - 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 MockView(BaseView): - name = example - self.assertEquals(get_name(MockView()), example) + # This has been turned off now. + #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 MockView(BaseView): + # name = example + # self.assertEquals(get_name(MockView()), example) def test_resource_description_uses_docstring_by_default(self): """Ensure Resource names are based on the docstring by default.""" @@ -66,20 +67,21 @@ class TestViewNamesAndDescriptions(TestCase): self.assertEquals(get_description(MockView()), 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 MockView(BaseView): - """docstring""" - description = example - self.assertEquals(get_description(MockView()), example) + # This has been turned off now + #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 MockView(BaseView): + # """docstring""" + # description = example + # self.assertEquals(get_description(MockView()), 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 MockView(BaseView): - description = example - self.assertEquals(get_description(MockView()), 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 MockView(BaseView): + # description = example + # self.assertEquals(get_description(MockView()), 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""" diff --git a/djangorestframework/utils/description.py b/djangorestframework/utils/description.py index f7145c0f8..25bef80bd 100644 --- a/djangorestframework/utils/description.py +++ b/djangorestframework/utils/description.py @@ -1,37 +1,91 @@ -"""Get a descriptive name and description for a view, -based on class name and docstring, and override-able by 'name' and 'description' attributes""" +""" +Get a descriptive name and description for a view. +""" import re +from djangorestframework.resources import Resource, FormResource, ModelResource + + +# These a a bit Grungy, but they do the job. def get_name(view): - """Return a name for the 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 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: + # If we're looking up the name of a view callable, as found by reverse, + # grok the class instance that we stored when as_view was called. + if getattr(view, 'cls_instance', None): + view = view.cls_instance + + # If this view has a resource that's been overridden, then use that resource for the name + if getattr(view, 'resource', None) not in (None, Resource, FormResource, ModelResource): + name = view.resource.__name__ + + # Chomp of any non-descriptive trailing part of the resource class name + if name.endswith('Resource') and name != 'Resource': + name = name[:-len('Resource')] + + # If the view has a descriptive suffix, eg '*** List', '*** Instance' + if getattr(view, '_suffix', None): + name += view._suffix + + # Otherwise if it's a function view use the function's name + elif 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 + + # If it's a view class with no resource then grok the name from the class name + elif getattr(view, '__class__', None) is not None: name = view.__class__.__name__ + + # Chomp of any non-descriptive trailing part of the view class name + if name.endswith('View') and name != 'View': + name = name[:-len('View')] + + # I ain't got nuthin fo' ya 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. + """ + 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') + By default this is the view's docstring with nice unindention applied. + """ - 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 we're looking up the name of a view callable, as found by reverse, + # grok the class instance that we stored when as_view was called. + if getattr(view, 'cls_instance', None): + view = view.cls_instance + - if whitespace_counts: - whitespace_pattern = '^' + (' ' * min(whitespace_counts)) - return re.sub(re.compile(whitespace_pattern, re.MULTILINE), '', view.__doc__) - - return view.__doc__ + # If this view has a resource that's been overridden, then use the resource's doctring + if getattr(view, 'resource', None) not in (None, Resource, FormResource, ModelResource): + doc = view.resource.__doc__ + + # Otherwise use the view doctring + elif getattr(view, '__doc__', None): + doc = view.__doc__ + + # I ain't got nuthin fo' ya + else: + return '' + + if not doc: + return '' + + whitespace_counts = [len(line) - len(line.lstrip(' ')) for line in doc.splitlines()[1:] if line.lstrip()] + + # unindent the docstring if needed + if whitespace_counts: + whitespace_pattern = '^' + (' ' * min(whitespace_counts)) + return re.sub(re.compile(whitespace_pattern, re.MULTILINE), '', doc) + + # otherwise return it as-is + return doc - return '' \ No newline at end of file diff --git a/djangorestframework/views.py b/djangorestframework/views.py index 81567e687..a0471166e 100644 --- a/djangorestframework/views.py +++ b/djangorestframework/views.py @@ -51,6 +51,18 @@ class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View): name = None description = None + @classmethod + def as_view(cls, **initkwargs): + """ + Override the default :meth:`as_view` to store an instance of the view + as an attribute on the callable function. This allows us to discover + information about the view when we do URL reverse lookups. + """ + view = super(BaseView, cls).as_view(**initkwargs) + view.cls_instance = cls(**initkwargs) + return view + + @property def allowed_methods(self): """ @@ -122,12 +134,12 @@ class ModelView(BaseView): class InstanceModelView(InstanceMixin, ReadModelMixin, UpdateModelMixin, DeleteModelMixin, ModelView): """A view which provides default operations for read/update/delete against a model instance.""" - pass + _suffix = 'Instance' class ListModelView(ListModelMixin, ModelView): - """A view which provides default operations for list, against a model in the database.""" - pass + """A view which provides default operations for list, against a model in the database.""" + _suffix = 'List' class ListOrCreateModelView(ListModelMixin, CreateModelMixin, ModelView): - """A view which provides default operations for list and create, against a model in the database.""" - pass + """A view which provides default operations for list and create, against a model in the database.""" + _suffix = 'List' diff --git a/examples/blogpost/urls.py b/examples/blogpost/urls.py index 130363b17..c677b8fad 100644 --- a/examples/blogpost/urls.py +++ b/examples/blogpost/urls.py @@ -6,7 +6,11 @@ from djangorestframework.resources import ModelResource from blogpost.models import BlogPost, Comment + class BlogPostResource(ModelResource): + """ + A Blog Post has a *title* and *content*, and can be associated with zero or more comments. + """ model = BlogPost fields = ('created', 'title', 'slug', 'content', 'url', 'comments') ordering = ('-created',) @@ -14,7 +18,11 @@ class BlogPostResource(ModelResource): def comments(self, instance): return reverse('comments', kwargs={'blogpost': instance.key}) + class CommentResource(ModelResource): + """ + A Comment is associated with a given Blog Post and has a *username* and *comment*, and optionally a *rating*. + """ model = Comment fields = ('username', 'comment', 'created', 'rating', 'url', 'blogpost') ordering = ('-created',) @@ -25,4 +33,4 @@ urlpatterns = patterns('', url(r'^(?P[^/]+)/$', InstanceModelView.as_view(resource=BlogPostResource)), url(r'^(?P[^/]+)/comments/$', ListOrCreateModelView.as_view(resource=CommentResource), name='comments'), url(r'^(?P[^/]+)/comments/(?P[^/]+)/$', InstanceModelView.as_view(resource=CommentResource)), -) \ No newline at end of file +) From 370274f5640d55ef71422f7a2440710a43ff900e Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 24 May 2011 10:27:24 +0100 Subject: [PATCH 61/70] Allow views to return HttpResponses. Add initial() hook method --- djangorestframework/mixins.py | 2 +- djangorestframework/tests/accept.py | 4 +-- djangorestframework/tests/authentication.py | 4 +-- djangorestframework/tests/breadcrumbs.py | 12 ++++----- djangorestframework/tests/description.py | 14 +++++------ djangorestframework/tests/files.py | 4 +-- djangorestframework/tests/parsers.py | 8 +++--- djangorestframework/tests/renderers.py | 4 +-- djangorestframework/tests/reverse.py | 4 +-- djangorestframework/tests/throttling.py | 4 +-- djangorestframework/tests/validators.py | 18 +++++++------- djangorestframework/views.py | 27 +++++++++++++++------ examples/objectstore/views.py | 6 ++--- examples/pygments_api/views.py | 6 ++--- examples/resourceexample/views.py | 6 ++--- examples/sandbox/views.py | 4 +-- 16 files changed, 70 insertions(+), 57 deletions(-) diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 622575923..90c759708 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -216,7 +216,7 @@ class RequestMixin(object): class ResponseMixin(object): """ - Adds behavior for pluggable `Renderers` to a :class:`views.BaseView` or Django :class:`View` class. + Adds behavior for pluggable `Renderers` to a :class:`views.View` class. Default behavior is to use standard HTTP Accept header content negotiation. Also supports overriding the content type by specifying an ``_accept=`` parameter in the URL. diff --git a/djangorestframework/tests/accept.py b/djangorestframework/tests/accept.py index 293a72847..f26de633f 100644 --- a/djangorestframework/tests/accept.py +++ b/djangorestframework/tests/accept.py @@ -1,6 +1,6 @@ from django.test import TestCase from djangorestframework.compat import RequestFactory -from djangorestframework.views import BaseView +from djangorestframework.views import View # See: http://www.useragentstring.com/ @@ -19,7 +19,7 @@ class UserAgentMungingTest(TestCase): def setUp(self): - class MockView(BaseView): + class MockView(View): permissions = () def get(self, request): diff --git a/djangorestframework/tests/authentication.py b/djangorestframework/tests/authentication.py index 04ac471ad..8254403c4 100644 --- a/djangorestframework/tests/authentication.py +++ b/djangorestframework/tests/authentication.py @@ -6,13 +6,13 @@ from django.test import Client, TestCase from django.utils import simplejson as json from djangorestframework.compat import RequestFactory -from djangorestframework.views import BaseView +from djangorestframework.views import View from djangorestframework import permissions import base64 -class MockView(BaseView): +class MockView(View): permissions = ( permissions.IsAuthenticated, ) def post(self, request): return {'a':1, 'b':2, 'c':3} diff --git a/djangorestframework/tests/breadcrumbs.py b/djangorestframework/tests/breadcrumbs.py index 1fd756347..158f18008 100644 --- a/djangorestframework/tests/breadcrumbs.py +++ b/djangorestframework/tests/breadcrumbs.py @@ -1,21 +1,21 @@ from django.conf.urls.defaults import patterns, url from django.test import TestCase from djangorestframework.utils.breadcrumbs import get_breadcrumbs -from djangorestframework.views import BaseView +from djangorestframework.views import View -class Root(BaseView): +class Root(View): pass -class ResourceRoot(BaseView): +class ResourceRoot(View): pass -class ResourceInstance(BaseView): +class ResourceInstance(View): pass -class NestedResourceRoot(BaseView): +class NestedResourceRoot(View): pass -class NestedResourceInstance(BaseView): +class NestedResourceInstance(View): pass urlpatterns = patterns('', diff --git a/djangorestframework/tests/description.py b/djangorestframework/tests/description.py index 6c494efaa..1ce291124 100644 --- a/djangorestframework/tests/description.py +++ b/djangorestframework/tests/description.py @@ -1,5 +1,5 @@ from django.test import TestCase -from djangorestframework.views import BaseView +from djangorestframework.views import View from djangorestframework.compat import apply_markdown from djangorestframework.utils.description import get_name, get_description @@ -35,7 +35,7 @@ MARKED_DOWN = """

an example docstring

class TestViewNamesAndDescriptions(TestCase): def test_resource_name_uses_classname_by_default(self): """Ensure Resource names are based on the classname by default.""" - class MockView(BaseView): + class MockView(View): pass self.assertEquals(get_name(MockView()), 'Mock') @@ -43,13 +43,13 @@ class TestViewNamesAndDescriptions(TestCase): #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 MockView(BaseView): + # class MockView(View): # name = example # self.assertEquals(get_name(MockView()), example) def test_resource_description_uses_docstring_by_default(self): """Ensure Resource names are based on the docstring by default.""" - class MockView(BaseView): + class MockView(View): """an example docstring ==================== @@ -71,7 +71,7 @@ class TestViewNamesAndDescriptions(TestCase): #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 MockView(BaseView): + # class MockView(View): # """docstring""" # description = example # self.assertEquals(get_description(MockView()), example) @@ -79,13 +79,13 @@ class TestViewNamesAndDescriptions(TestCase): #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 MockView(BaseView): + # class MockView(View): # description = example # self.assertEquals(get_description(MockView()), 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 MockView(BaseView): + class MockView(View): pass self.assertEquals(get_description(MockView()), '') diff --git a/djangorestframework/tests/files.py b/djangorestframework/tests/files.py index 3892422c1..25aad9b49 100644 --- a/djangorestframework/tests/files.py +++ b/djangorestframework/tests/files.py @@ -1,7 +1,7 @@ from django.test import TestCase from django import forms from djangorestframework.compat import RequestFactory -from djangorestframework.views import BaseView +from djangorestframework.views import View from djangorestframework.resources import FormResource import StringIO @@ -19,7 +19,7 @@ class UploadFilesTests(TestCase): class MockResource(FormResource): form = FileForm - class MockView(BaseView): + class MockView(View): permissions = () resource = MockResource diff --git a/djangorestframework/tests/parsers.py b/djangorestframework/tests/parsers.py index 764a8f5cb..3ab1a61c8 100644 --- a/djangorestframework/tests/parsers.py +++ b/djangorestframework/tests/parsers.py @@ -2,11 +2,11 @@ # .. # >>> from djangorestframework.parsers import FormParser # >>> from djangorestframework.compat import RequestFactory -# >>> from djangorestframework.views import BaseView +# >>> from djangorestframework.views import View # >>> from StringIO import StringIO # >>> from urllib import urlencode # >>> req = RequestFactory().get('/') -# >>> some_view = BaseView() +# >>> some_view = View() # >>> some_view.request = req # Make as if this request had been dispatched # # FormParser @@ -85,7 +85,7 @@ # from django.test import TestCase # from djangorestframework.compat import RequestFactory # from djangorestframework.parsers import MultiPartParser -# from djangorestframework.views import BaseView +# from djangorestframework.views import View # from StringIO import StringIO # # def encode_multipart_formdata(fields, files): @@ -125,7 +125,7 @@ # def test_multipartparser(self): # """Ensure that MultiPartParser can parse multipart/form-data that contains a mix of several files and parameters.""" # post_req = RequestFactory().post('/', self.body, content_type=self.content_type) -# view = BaseView() +# view = View() # view.request = post_req # (data, files) = MultiPartParser(view).parse(StringIO(self.body)) # self.assertEqual(data['key1'], 'val1') diff --git a/djangorestframework/tests/renderers.py b/djangorestframework/tests/renderers.py index fcc405a19..5364cd2e9 100644 --- a/djangorestframework/tests/renderers.py +++ b/djangorestframework/tests/renderers.py @@ -1,7 +1,7 @@ from django.conf.urls.defaults import patterns, url from django import http from django.test import TestCase -from djangorestframework.compat import View +from djangorestframework.compat import View as DjangoView from djangorestframework.renderers import BaseRenderer, JSONRenderer from djangorestframework.mixins import ResponseMixin from djangorestframework.response import Response @@ -13,7 +13,7 @@ DUMMYCONTENT = 'dummycontent' RENDERER_A_SERIALIZER = lambda x: 'Renderer A: %s' % x RENDERER_B_SERIALIZER = lambda x: 'Renderer B: %s' % x -class MockView(ResponseMixin, View): +class MockView(ResponseMixin, DjangoView): def get(self, request): response = Response(DUMMYSTATUS, DUMMYCONTENT) return self.render(response) diff --git a/djangorestframework/tests/reverse.py b/djangorestframework/tests/reverse.py index 7026d4a47..b4b0a7938 100644 --- a/djangorestframework/tests/reverse.py +++ b/djangorestframework/tests/reverse.py @@ -3,10 +3,10 @@ from django.core.urlresolvers import reverse from django.test import TestCase from django.utils import simplejson as json -from djangorestframework.views import BaseView +from djangorestframework.views import View -class MockView(BaseView): +class MockView(View): """Mock resource which simply returns a URL, so that we can ensure that reversed URLs are fully qualified""" permissions = () diff --git a/djangorestframework/tests/throttling.py b/djangorestframework/tests/throttling.py index e7a054cd0..a8f08b184 100644 --- a/djangorestframework/tests/throttling.py +++ b/djangorestframework/tests/throttling.py @@ -3,11 +3,11 @@ from django.test import TestCase from django.utils import simplejson as json from djangorestframework.compat import RequestFactory -from djangorestframework.views import BaseView +from djangorestframework.views import View from djangorestframework.permissions import PerUserThrottling -class MockView(BaseView): +class MockView(View): permissions = ( PerUserThrottling, ) throttle = (3, 1) # 3 requests per second diff --git a/djangorestframework/tests/validators.py b/djangorestframework/tests/validators.py index 32fb10029..a1e5d2d71 100644 --- a/djangorestframework/tests/validators.py +++ b/djangorestframework/tests/validators.py @@ -4,7 +4,7 @@ from django.test import TestCase from djangorestframework.compat import RequestFactory from djangorestframework.resources import Resource, FormResource, ModelResource from djangorestframework.response import ErrorResponse -from djangorestframework.views import BaseView +from djangorestframework.views import View from djangorestframework.resources import Resource @@ -18,7 +18,7 @@ class TestDisabledValidations(TestCase): class DisabledFormResource(FormResource): form = None - class MockView(BaseView): + class MockView(View): resource = DisabledFormResource view = MockView() @@ -31,7 +31,7 @@ class TestDisabledValidations(TestCase): class DisabledFormResource(FormResource): form = None - class MockView(BaseView): + class MockView(View): resource = DisabledFormResource view = MockView() @@ -43,7 +43,7 @@ class TestDisabledValidations(TestCase): """If the view's form is None and does not have a Resource with a model set then ModelFormValidator(view).validate_request(content, None) should just return the content unmodified.""" - class DisabledModelFormView(BaseView): + class DisabledModelFormView(View): resource = ModelResource view = DisabledModelFormView() @@ -52,7 +52,7 @@ class TestDisabledValidations(TestCase): 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 DisabledModelFormView(BaseView): + class DisabledModelFormView(View): resource = ModelResource view = DisabledModelFormView() @@ -77,7 +77,7 @@ class TestNonFieldErrors(TestCase): class MockResource(FormResource): form = MockForm - class MockView(BaseView): + class MockView(View): pass view = MockView() @@ -104,10 +104,10 @@ class TestFormValidation(TestCase): class MockModelResource(ModelResource): form = MockForm - class MockFormView(BaseView): + class MockFormView(View): resource = MockFormResource - class MockModelFormView(BaseView): + class MockModelFormView(View): resource = MockModelResource self.MockFormResource = MockFormResource @@ -277,7 +277,7 @@ class TestModelFormValidator(TestCase): class MockResource(ModelResource): model = MockModel - class MockView(BaseView): + class MockView(View): resource = MockResource self.validator = MockResource(MockView) diff --git a/djangorestframework/views.py b/djangorestframework/views.py index 545671a4c..3d6a6c403 100644 --- a/djangorestframework/views.py +++ b/djangorestframework/views.py @@ -6,16 +6,17 @@ By setting or modifying class attributes on your view, you change it's predefine """ from django.core.urlresolvers import set_script_prefix +from django.http import HttpResponse from django.views.decorators.csrf import csrf_exempt -from djangorestframework.compat import View +from djangorestframework.compat import View as DjangoView from djangorestframework.response import Response, ErrorResponse from djangorestframework.mixins import * from djangorestframework import resources, renderers, parsers, authentication, permissions, status __all__ = ( - 'BaseView', + 'View', 'ModelView', 'InstanceModelView', 'ListModelView', @@ -24,7 +25,7 @@ __all__ = ( -class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View): +class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): """ Handles incoming requests and maps them to REST operations. Performs request deserialization, response serialization, authentication and input validation. @@ -65,7 +66,7 @@ class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View): as an attribute on the callable function. This allows us to discover information about the view when we do URL reverse lookups. """ - view = super(BaseView, cls).as_view(**initkwargs) + view = super(View, cls).as_view(**initkwargs) view.cls_instance = cls(**initkwargs) return view @@ -86,6 +87,14 @@ class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View): {'detail': 'Method \'%s\' not allowed on this resource.' % self.method}) + def initial(self, request, *args, **kargs): + """ + Hook for any code that needs to run prior to anything else. + Required if you want to do things like set `request.upload_handlers` before + the authentication and dispatch handling is run. + """ + pass + # Note: session based authentication is explicitly CSRF validated, # all other authentication is CSRF exempt. @csrf_exempt @@ -99,6 +108,8 @@ class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View): set_script_prefix(prefix) try: + self.initial(request, *args, **kwargs) + # Authenticate and check request has the relevant permissions self._check_permissions() @@ -110,8 +121,10 @@ class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View): response_obj = handler(request, *args, **kwargs) - # Allow return value to be either Response, or an object, or None - if isinstance(response_obj, Response): + # Allow return value to be either HttpResponse, Response, or an object, or None + if isinstance(response_obj, HttpResponse): + return response_obj + elif isinstance(response_obj, Response): response = response_obj elif response_obj is not None: response = Response(status.HTTP_200_OK, response_obj) @@ -135,7 +148,7 @@ class BaseView(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, View): -class ModelView(BaseView): +class ModelView(View): """A RESTful view that maps to a model in the database.""" resource = resources.ModelResource diff --git a/examples/objectstore/views.py b/examples/objectstore/views.py index 076e59416..19999aa95 100644 --- a/examples/objectstore/views.py +++ b/examples/objectstore/views.py @@ -1,7 +1,7 @@ from django.conf import settings from django.core.urlresolvers import reverse -from djangorestframework.views import BaseView +from djangorestframework.views import View from djangorestframework.response import Response from djangorestframework import status @@ -25,7 +25,7 @@ def remove_oldest_files(dir, max_files): [os.remove(path) for path in ctime_sorted_paths[max_files:]] -class ObjectStoreRoot(BaseView): +class ObjectStoreRoot(View): """ Root of the Object Store API. Allows the client to get a complete list of all the stored objects, or to create a new stored object. @@ -51,7 +51,7 @@ class ObjectStoreRoot(BaseView): return Response(status.HTTP_201_CREATED, self.CONTENT, {'Location': reverse('stored-object', kwargs={'key':key})}) -class StoredObject(BaseView): +class StoredObject(View): """ Represents a stored object. The object may be any picklable content. diff --git a/examples/pygments_api/views.py b/examples/pygments_api/views.py index e6bfae489..76647107f 100644 --- a/examples/pygments_api/views.py +++ b/examples/pygments_api/views.py @@ -5,7 +5,7 @@ from django.core.urlresolvers import reverse from djangorestframework.resources import FormResource from djangorestframework.response import Response from djangorestframework.renderers import BaseRenderer -from djangorestframework.views import BaseView +from djangorestframework.views import View from djangorestframework import status from pygments.formatters import HtmlFormatter @@ -53,7 +53,7 @@ class PygmentsFormResource(FormResource): form = PygmentsForm -class PygmentsRoot(BaseView): +class PygmentsRoot(View): """ This example demonstrates a simple RESTful Web API aound the awesome pygments library. This top level resource is used to create highlighted code snippets, and to list all the existing code snippets. @@ -88,7 +88,7 @@ class PygmentsRoot(BaseView): return Response(status.HTTP_201_CREATED, headers={'Location': reverse('pygments-instance', args=[unique_id])}) -class PygmentsInstance(BaseView): +class PygmentsInstance(View): """ Simply return the stored highlighted HTML file with the correct mime type. This Resource only renders HTML and uses a standard HTML renderer rather than the renderers.DocumentingHTMLRenderer class. diff --git a/examples/resourceexample/views.py b/examples/resourceexample/views.py index 70d96891b..29651fbf5 100644 --- a/examples/resourceexample/views.py +++ b/examples/resourceexample/views.py @@ -1,6 +1,6 @@ from django.core.urlresolvers import reverse -from djangorestframework.views import BaseView +from djangorestframework.views import View from djangorestframework.resources import FormResource from djangorestframework.response import Response from djangorestframework import status @@ -14,7 +14,7 @@ class MyFormValidation(FormResource): form = MyForm -class ExampleResource(BaseView): +class ExampleResource(View): """ A basic read-only resource that points to 3 other resources. """ @@ -23,7 +23,7 @@ class ExampleResource(BaseView): return {"Some other resources": [reverse('another-example-resource', kwargs={'num':num}) for num in range(3)]} -class AnotherExampleResource(BaseView): +class AnotherExampleResource(View): """ A basic GET-able/POST-able resource. """ diff --git a/examples/sandbox/views.py b/examples/sandbox/views.py index d5b59284b..1c55c28f2 100644 --- a/examples/sandbox/views.py +++ b/examples/sandbox/views.py @@ -1,10 +1,10 @@ """The root view for the examples provided with Django REST framework""" from django.core.urlresolvers import reverse -from djangorestframework.views import BaseView +from djangorestframework.views import View -class Sandbox(BaseView): +class Sandbox(View): """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. From eafda8550800a98aa37571df0cef78e32521a89b Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 24 May 2011 10:40:35 +0100 Subject: [PATCH 62/70] Fix up breadcrumbs to only breadcrumb for REST framework Views --- djangorestframework/tests/breadcrumbs.py | 10 +++++----- djangorestframework/utils/breadcrumbs.py | 5 ++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/djangorestframework/tests/breadcrumbs.py b/djangorestframework/tests/breadcrumbs.py index 158f18008..dc1a02bf9 100644 --- a/djangorestframework/tests/breadcrumbs.py +++ b/djangorestframework/tests/breadcrumbs.py @@ -19,11 +19,11 @@ class NestedResourceInstance(View): pass urlpatterns = patterns('', - url(r'^$', Root), - url(r'^resource/$', ResourceRoot), - url(r'^resource/(?P[0-9]+)$', ResourceInstance), - url(r'^resource/(?P[0-9]+)/$', NestedResourceRoot), - url(r'^resource/(?P[0-9]+)/(?P[A-Za-z]+)$', NestedResourceInstance), + url(r'^$', Root.as_view()), + url(r'^resource/$', ResourceRoot.as_view()), + url(r'^resource/(?P[0-9]+)$', ResourceInstance.as_view()), + url(r'^resource/(?P[0-9]+)/$', NestedResourceRoot.as_view()), + url(r'^resource/(?P[0-9]+)/(?P[A-Za-z]+)$', NestedResourceInstance.as_view()), ) diff --git a/djangorestframework/utils/breadcrumbs.py b/djangorestframework/utils/breadcrumbs.py index 1e604efce..0b043c78c 100644 --- a/djangorestframework/utils/breadcrumbs.py +++ b/djangorestframework/utils/breadcrumbs.py @@ -4,6 +4,8 @@ from djangorestframework.utils.description import get_name def get_breadcrumbs(url): """Given a url returns a list of breadcrumbs, which are each a tuple of (name, url).""" + from djangorestframework.views import View + def breadcrumbs_recursive(url, breadcrumbs_list): """Add tuples of (name, url) to the breadcrumbs list, progressively chomping off parts of the url.""" @@ -12,7 +14,8 @@ def get_breadcrumbs(url): except: pass else: - if callable(view): + # Check if this is a REST framework view, and if so add it to the breadcrumbs + if isinstance(getattr(view, 'cls_instance', None), View): breadcrumbs_list.insert(0, (get_name(view), url)) if url == '': From ce6e5fdc01b6d820f317bc1d8edc4ede4a946516 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 24 May 2011 13:29:30 +0100 Subject: [PATCH 63/70] Renderers can now cope with parameterised args. ResponseMixin gets cleaned up & added Renderer.can_handle_response(), mirroring Parsers.can_handle_request() --- djangorestframework/mixins.py | 75 +++++---------- djangorestframework/parsers.py | 2 +- djangorestframework/renderers.py | 45 ++++++++- .../templatetags/add_query_param.py | 2 +- djangorestframework/tests/renderers.py | 15 +-- djangorestframework/utils/__init__.py | 3 + djangorestframework/utils/mediatypes.py | 77 +++++++++------ djangorestframework/views.py | 94 ++++++++++--------- 8 files changed, 173 insertions(+), 140 deletions(-) diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 90c759708..d99b6f15b 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -14,7 +14,7 @@ from djangorestframework.parsers import FormParser, MultiPartParser from djangorestframework.resources import Resource from djangorestframework.response import Response, ErrorResponse from djangorestframework.utils import as_tuple, MSIE_USER_AGENT_REGEX -from djangorestframework.utils.mediatypes import is_form_media_type +from djangorestframework.utils.mediatypes import is_form_media_type, order_by_precedence from decimal import Decimal import re @@ -206,7 +206,7 @@ class RequestMixin(object): @property def _default_parser(self): """ - Return the view's default parser. + Return the view's default parser class. """ return self.parsers[0] @@ -245,15 +245,15 @@ class ResponseMixin(object): try: renderer = self._determine_renderer(self.request) except ErrorResponse, exc: - renderer = self._default_renderer + renderer = self._default_renderer(self) response = exc.response # Serialize the response content # TODO: renderer.media_type isn't the right thing to do here... if response.has_content_body: - content = renderer(self).render(response.cleaned_content, renderer.media_type) + content = renderer.render(response.cleaned_content, renderer.media_type) else: - content = renderer(self).render() + content = renderer.render() # Build the HTTP Response # TODO: renderer.media_type isn't the right thing to do here... @@ -264,10 +264,6 @@ class ResponseMixin(object): return resp - # TODO: This should be simpler now. - # Add a handles_response() to the renderer, then iterate through the - # acceptable media types, ordered by how specific they are, - # calling handles_response on each renderer. def _determine_renderer(self, request): """ Return the appropriate renderer for the output, given the client's 'Accept' header, @@ -282,60 +278,33 @@ class ResponseMixin(object): elif (self._IGNORE_IE_ACCEPT_HEADER and request.META.has_key('HTTP_USER_AGENT') and MSIE_USER_AGENT_REGEX.match(request.META['HTTP_USER_AGENT'])): + # Ignore MSIE's broken accept behavior and do something sensible instead accept_list = ['text/html', '*/*'] elif request.META.has_key('HTTP_ACCEPT'): # Use standard HTTP Accept negotiation - accept_list = request.META["HTTP_ACCEPT"].split(',') + accept_list = [token.strip() for token in request.META["HTTP_ACCEPT"].split(',')] else: # No accept header specified - return self._default_renderer - - # 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 + return self._default_renderer(self) - 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)] + # Check the acceptable media types against each renderer, + # attempting more specific media types first + # NB. The inner loop here isn't as bad as it first looks :) + # We're effectivly looping over max len(accept_list) * len(self.renderers) + renderers = [renderer_cls(self) for renderer_cls in self.renderers] + + for media_type_lst in order_by_precedence(accept_list): + for renderer in renderers: + for media_type in media_type_lst: + if renderer.can_handle_response(media_type): + return renderer - for accept_set in accept_sets: - # Return any exact match - for renderer in self.renderers: - if renderer.media_type in accept_set: - return renderer - - # Return any subtype match - for renderer in self.renderers: - if renderer.media_type.split('/')[0] + '/*' in accept_set: - return renderer - - # Return default - if '*/*' in accept_set: - return self._default_renderer - - + # No acceptable renderers were found raise ErrorResponse(status.HTTP_406_NOT_ACCEPTABLE, {'detail': 'Could not satisfy the client\'s Accept header', 'available_types': self._rendered_media_types}) + @property def _rendered_media_types(self): """ @@ -346,7 +315,7 @@ class ResponseMixin(object): @property def _default_renderer(self): """ - Return the view's default renderer. + Return the view's default renderer class. """ return self.renderers[0] diff --git a/djangorestframework/parsers.py b/djangorestframework/parsers.py index 7c76bcc69..726e09e97 100644 --- a/djangorestframework/parsers.py +++ b/djangorestframework/parsers.py @@ -54,7 +54,7 @@ class BaseParser(object): This may be overridden to provide for other behavior, but typically you'll instead want to just set the :attr:`media_type` attribute on the class. """ - return media_type_matches(content_type, self.media_type) + return media_type_matches(self.media_type, content_type) def parse(self, stream): """ diff --git a/djangorestframework/renderers.py b/djangorestframework/renderers.py index 3e59511c3..245bfdfea 100644 --- a/djangorestframework/renderers.py +++ b/djangorestframework/renderers.py @@ -16,7 +16,7 @@ from djangorestframework.compat import apply_markdown from djangorestframework.utils import dict2xml, url_resolves from djangorestframework.utils.breadcrumbs import get_breadcrumbs from djangorestframework.utils.description import get_name, get_description -from djangorestframework.utils.mediatypes import get_media_type_params, add_media_type_param +from djangorestframework.utils.mediatypes import get_media_type_params, add_media_type_param, media_type_matches from decimal import Decimal import re @@ -39,11 +39,26 @@ class BaseRenderer(object): All renderers must extend this class, set the :attr:`media_type` attribute, and override the :meth:`render` method. """ + media_type = None def __init__(self, view): self.view = view + def can_handle_response(self, accept): + """ + Returns :const:`True` if this renderer is able to deal with the given + *accept* media type. + + The default implementation for this function is to check the *accept* + argument against the :attr:`media_type` attribute set on the class to see if + they match. + + This may be overridden to provide for other behavior, but typically you'll + instead want to just set the :attr:`media_type` attribute on the class. + """ + return media_type_matches(self.media_type, accept) + def render(self, obj=None, media_type=None): """ Given an object render it into a string. @@ -66,9 +81,13 @@ class JSONRenderer(BaseRenderer): """ Renderer which serializes to JSON """ + media_type = 'application/json' def render(self, obj=None, media_type=None): + """ + Renders *obj* into serialized JSON. + """ if obj is None: return '' @@ -92,6 +111,9 @@ class XMLRenderer(BaseRenderer): media_type = 'application/xml' def render(self, obj=None, media_type=None): + """ + Renders *obj* into serialized XML. + """ if obj is None: return '' return dict2xml(obj) @@ -103,17 +125,22 @@ class TemplateRenderer(BaseRenderer): Render the object simply by using the given template. To create a template renderer, subclass this class, and set - the :attr:`media_type` and `:attr:template` attributes. + the :attr:`media_type` and :attr:`template` attributes. """ + media_type = None template = None def render(self, obj=None, media_type=None): + """ + Renders *obj* using the :attr:`template` specified on the class. + """ if obj is None: return '' - context = RequestContext(self.request, obj) - return self.template.render(context) + template = loader.get_template(self.template) + context = RequestContext(self.view.request, {'object': obj}) + return template.render(context) class DocumentingTemplateRenderer(BaseRenderer): @@ -121,6 +148,7 @@ class DocumentingTemplateRenderer(BaseRenderer): Base class for renderers used to self-document the API. Implementing classes should extend this class and set the template attribute. """ + template = None def _get_content(self, view, request, obj, media_type): @@ -215,6 +243,12 @@ class DocumentingTemplateRenderer(BaseRenderer): def render(self, obj=None, media_type=None): + """ + Renders *obj* using the :attr:`template` set on the class. + + The context used in the template contains all the information + needed to self-document the response to this request. + """ content = self._get_content(self.view, self.view.request, obj, media_type) form_instance = self._get_form_instance(self.view) @@ -272,6 +306,7 @@ class DocumentingHTMLRenderer(DocumentingTemplateRenderer): Renderer which provides a browsable HTML interface for an API. See the examples at http://api.django-rest-framework.org to see this in action. """ + media_type = 'text/html' template = 'renderer.html' @@ -282,6 +317,7 @@ class DocumentingXHTMLRenderer(DocumentingTemplateRenderer): We need this to be listed in preference to xml in order to return HTML to WebKit based browsers, given their Accept headers. """ + media_type = 'application/xhtml+xml' template = 'renderer.html' @@ -292,6 +328,7 @@ class DocumentingPlainTextRenderer(DocumentingTemplateRenderer): documentation of the returned status and headers, and of the resource's name and description. Useful for browsing an API with command line tools. """ + media_type = 'text/plain' template = 'renderer.txt' diff --git a/djangorestframework/templatetags/add_query_param.py b/djangorestframework/templatetags/add_query_param.py index 91c1a312b..94833bced 100644 --- a/djangorestframework/templatetags/add_query_param.py +++ b/djangorestframework/templatetags/add_query_param.py @@ -4,7 +4,7 @@ from urllib import quote register = Library() def add_query_param(url, param): - (key, val) = param.split('=') + (key, sep, val) = param.partition('=') param = '%s=%s' % (key, quote(val)) (scheme, netloc, path, params, query, fragment) = urlparse(url) if query: diff --git a/djangorestframework/tests/renderers.py b/djangorestframework/tests/renderers.py index 5364cd2e9..542769931 100644 --- a/djangorestframework/tests/renderers.py +++ b/djangorestframework/tests/renderers.py @@ -13,23 +13,24 @@ DUMMYCONTENT = 'dummycontent' RENDERER_A_SERIALIZER = lambda x: 'Renderer A: %s' % x RENDERER_B_SERIALIZER = lambda x: 'Renderer B: %s' % x -class MockView(ResponseMixin, DjangoView): - def get(self, request): - response = Response(DUMMYSTATUS, DUMMYCONTENT) - return self.render(response) - class RendererA(BaseRenderer): media_type = 'mock/renderera' - def render(self, obj=None, content_type=None): + def render(self, obj=None, media_type=None): return RENDERER_A_SERIALIZER(obj) class RendererB(BaseRenderer): media_type = 'mock/rendererb' - def render(self, obj=None, content_type=None): + def render(self, obj=None, media_type=None): return RENDERER_B_SERIALIZER(obj) +class MockView(ResponseMixin, DjangoView): + renderers = (RendererA, RendererB) + + def get(self, request): + response = Response(DUMMYSTATUS, DUMMYCONTENT) + return self.render(response) urlpatterns = patterns('', url(r'^$', MockView.as_view(renderers=[RendererA, RendererB])), diff --git a/djangorestframework/utils/__init__.py b/djangorestframework/utils/__init__.py index 67870001f..99f9724ce 100644 --- a/djangorestframework/utils/__init__.py +++ b/djangorestframework/utils/__init__.py @@ -13,6 +13,9 @@ import xml.etree.ElementTree as ET # """Adds the ADMIN_MEDIA_PREFIX to the request context.""" # return {'ADMIN_MEDIA_PREFIX': settings.ADMIN_MEDIA_PREFIX} +from mediatypes import media_type_matches, is_form_media_type +from mediatypes import add_media_type_param, get_media_type_params, order_by_precedence + MSIE_USER_AGENT_REGEX = re.compile(r'^Mozilla/[0-9]+\.[0-9]+ \([^)]*; MSIE [0-9]+\.[0-9]+[a-z]?;[^)]*\)(?!.* Opera )') def as_tuple(obj): diff --git a/djangorestframework/utils/mediatypes.py b/djangorestframework/utils/mediatypes.py index 190cdc2df..ae734e62d 100644 --- a/djangorestframework/utils/mediatypes.py +++ b/djangorestframework/utils/mediatypes.py @@ -51,6 +51,22 @@ def get_media_type_params(media_type): return _MediaType(media_type).params +def order_by_precedence(media_type_lst): + """ + Returns a list of lists of media type strings, ordered by precedence. + Precedence is determined by how specific a media type is: + + 3. 'type/subtype; param=val' + 2. 'type/subtype' + 1. 'type/*' + 0. '*/*' + """ + ret = [[],[],[],[]] + for media_type in media_type_lst: + precedence = _MediaType(media_type).precedence + ret[3-precedence].append(media_type) + return ret + class _MediaType(object): def __init__(self, media_type_str): @@ -61,53 +77,54 @@ class _MediaType(object): self.main_type, sep, self.sub_type = self.full_type.partition('/') def match(self, other): - """Return true if this MediaType satisfies the constraint of the given MediaType.""" - for key in other.params.keys(): - if key != 'q' and other.params[key] != self.params.get(key, None): + """Return true if this MediaType satisfies the given MediaType.""" + for key in self.params.keys(): + if key != 'q' and other.params.get(key, None) != self.params.get(key, None): return False - if other.sub_type != '*' and other.sub_type != self.sub_type: + if self.sub_type != '*' and other.sub_type != '*' and other.sub_type != self.sub_type: return False - if other.main_type != '*' and other.main_type != self.main_type: + if self.main_type != '*' and other.main_type != '*' and other.main_type != self.main_type: return False return True + @property def precedence(self): """ - Return a precedence level for the media type given how specific it is. + Return a precedence level from 0-3 for the media type given how specific it is. """ if self.main_type == '*': - return 1 + return 0 elif self.sub_type == '*': - return 2 + return 1 elif not self.params or self.params.keys() == ['q']: - return 3 - return 4 + return 2 + return 3 - def quality(self): - """ - Return a quality level for the media type. - """ - try: - return Decimal(self.params.get('q', '1.0')) - except: - return Decimal(0) + #def quality(self): + # """ + # Return a quality level for the media type. + # """ + # try: + # return Decimal(self.params.get('q', '1.0')) + # except: + # return Decimal(0) + + #def score(self): + # """ + # Return an overall score for a given media type given it's quality and precedence. + # """ + # # NB. quality values should only have up to 3 decimal points + # # http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.9 + # return self.quality * 10000 + self.precedence - def score(self): - """ - Return an overall score for a given media type given it's quality and precedence. - """ - # NB. quality values should only have up to 3 decimal points - # http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.9 - return self.quality * 10000 + self.precedence - - def as_tuple(self): - return (self.main_type, self.sub_type, self.params) + #def as_tuple(self): + # return (self.main_type, self.sub_type, self.params) - def __repr__(self): - return "" % (self.as_tuple(),) + #def __repr__(self): + # return "" % (self.as_tuple(),) def __str__(self): return unicode(self).encode('utf-8') diff --git a/djangorestframework/views.py b/djangorestframework/views.py index 3d6a6c403..ade90cac2 100644 --- a/djangorestframework/views.py +++ b/djangorestframework/views.py @@ -99,52 +99,58 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): # all other authentication is CSRF exempt. @csrf_exempt def dispatch(self, request, *args, **kwargs): - self.request = request - self.args = args - self.kwargs = kwargs - - # 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) - try: - self.initial(request, *args, **kwargs) - - # Authenticate and check request has the relevant permissions - self._check_permissions() - - # Get the appropriate handler method - if self.method.lower() in self.http_method_names: - handler = getattr(self, self.method.lower(), self.http_method_not_allowed) - else: - handler = self.http_method_not_allowed - - response_obj = handler(request, *args, **kwargs) - - # Allow return value to be either HttpResponse, Response, or an object, or None - if isinstance(response_obj, HttpResponse): - return response_obj - elif isinstance(response_obj, Response): - response = response_obj - elif response_obj is not None: - response = Response(status.HTTP_200_OK, response_obj) - else: - response = Response(status.HTTP_204_NO_CONTENT) - - # Pre-serialize filtering (eg filter complex objects into natively serializable types) - response.cleaned_content = self.filter_response(response.raw_content) + self.request = request + self.args = args + self.kwargs = kwargs - except ErrorResponse, exc: - response = exc.response - - # Always add these headers. - # - # TODO - this isn't actually the correct way to set the vary header, - # also it's currently sub-obtimal for HTTP caching - need to sort that out. - response.headers['Allow'] = ', '.join(self.allowed_methods) - response.headers['Vary'] = 'Authenticate, Accept' - - return self.render(response) + # 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) + + try: + self.initial(request, *args, **kwargs) + + # Authenticate and check request has the relevant permissions + self._check_permissions() + + # Get the appropriate handler method + if self.method.lower() in self.http_method_names: + handler = getattr(self, self.method.lower(), self.http_method_not_allowed) + else: + handler = self.http_method_not_allowed + + response_obj = handler(request, *args, **kwargs) + + # Allow return value to be either HttpResponse, Response, or an object, or None + if isinstance(response_obj, HttpResponse): + return response_obj + elif isinstance(response_obj, Response): + response = response_obj + elif response_obj is not None: + response = Response(status.HTTP_200_OK, response_obj) + else: + response = Response(status.HTTP_204_NO_CONTENT) + + # Pre-serialize filtering (eg filter complex objects into natively serializable types) + response.cleaned_content = self.filter_response(response.raw_content) + + except ErrorResponse, exc: + response = exc.response + + # Always add these headers. + # + # TODO - this isn't actually the correct way to set the vary header, + # also it's currently sub-obtimal for HTTP caching - need to sort that out. + response.headers['Allow'] = ', '.join(self.allowed_methods) + response.headers['Vary'] = 'Authenticate, Accept' + + return self.render(response) + + except: + import traceback + traceback.print_exc() + raise From 894bf34451d0b3ff73f75e3ea954088e9078bed8 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 24 May 2011 16:31:17 +0100 Subject: [PATCH 64/70] tidy up last bits of renderer media type handling --- djangorestframework/mixins.py | 25 +++++---- djangorestframework/response.py | 1 + djangorestframework/views.py | 93 +++++++++++++++------------------ 3 files changed, 59 insertions(+), 60 deletions(-) diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index d99b6f15b..4d1731787 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -243,21 +243,24 @@ class ResponseMixin(object): self.response = response try: - renderer = self._determine_renderer(self.request) + renderer, media_type = self._determine_renderer(self.request) except ErrorResponse, exc: renderer = self._default_renderer(self) + media_type = renderer.media_type response = exc.response + + # Set the media type of the response + # Note that the renderer *could* override it in .render() if required. + response.media_type = renderer.media_type # Serialize the response content - # TODO: renderer.media_type isn't the right thing to do here... if response.has_content_body: - content = renderer.render(response.cleaned_content, renderer.media_type) + content = renderer.render(response.cleaned_content, media_type) else: content = renderer.render() # Build the HTTP Response - # TODO: renderer.media_type isn't the right thing to do here... - resp = HttpResponse(content, mimetype=renderer.media_type, status=response.status) + resp = HttpResponse(content, mimetype=response.media_type, status=response.status) for (key, val) in response.headers.items(): resp[key] = val @@ -266,8 +269,10 @@ class ResponseMixin(object): def _determine_renderer(self, request): """ - Return the appropriate renderer for the output, given the client's 'Accept' header, - and the content types that this mixin knows how to serve. + Determines the appropriate renderer for the output, given the client's 'Accept' header, + and the :attr:`renderers` set on this class. + + Returns a 2-tuple of `(renderer, media_type)` See: RFC 2616, Section 14 - http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html """ @@ -285,19 +290,19 @@ class ResponseMixin(object): accept_list = [token.strip() for token in request.META["HTTP_ACCEPT"].split(',')] else: # No accept header specified - return self._default_renderer(self) + return (self._default_renderer(self), self._default_renderer.media_type) # Check the acceptable media types against each renderer, # attempting more specific media types first # NB. The inner loop here isn't as bad as it first looks :) - # We're effectivly looping over max len(accept_list) * len(self.renderers) + # Worst case is we're looping over len(accept_list) * len(self.renderers) renderers = [renderer_cls(self) for renderer_cls in self.renderers] for media_type_lst in order_by_precedence(accept_list): for renderer in renderers: for media_type in media_type_lst: if renderer.can_handle_response(media_type): - return renderer + return renderer, media_type # No acceptable renderers were found raise ErrorResponse(status.HTTP_406_NOT_ACCEPTABLE, diff --git a/djangorestframework/response.py b/djangorestframework/response.py index f6bbe3be3..d68ececf8 100644 --- a/djangorestframework/response.py +++ b/djangorestframework/response.py @@ -18,6 +18,7 @@ class Response(object): def __init__(self, status=200, content=None, headers={}): self.status = status + self.media_type = None self.has_content_body = content is not None self.raw_content = content # content prior to filtering self.cleaned_content = content # content after filtering diff --git a/djangorestframework/views.py b/djangorestframework/views.py index ade90cac2..cdb2ba33d 100644 --- a/djangorestframework/views.py +++ b/djangorestframework/views.py @@ -99,59 +99,52 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): # all other authentication is CSRF exempt. @csrf_exempt def dispatch(self, request, *args, **kwargs): + self.request = request + self.args = args + self.kwargs = kwargs + + # 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) + try: - self.request = request - self.args = args - self.kwargs = kwargs - - # 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) - - try: - self.initial(request, *args, **kwargs) - - # Authenticate and check request has the relevant permissions - self._check_permissions() - - # Get the appropriate handler method - if self.method.lower() in self.http_method_names: - handler = getattr(self, self.method.lower(), self.http_method_not_allowed) - else: - handler = self.http_method_not_allowed - - response_obj = handler(request, *args, **kwargs) - - # Allow return value to be either HttpResponse, Response, or an object, or None - if isinstance(response_obj, HttpResponse): - return response_obj - elif isinstance(response_obj, Response): - response = response_obj - elif response_obj is not None: - response = Response(status.HTTP_200_OK, response_obj) - else: - response = Response(status.HTTP_204_NO_CONTENT) - - # Pre-serialize filtering (eg filter complex objects into natively serializable types) - response.cleaned_content = self.filter_response(response.raw_content) + self.initial(request, *args, **kwargs) - except ErrorResponse, exc: - response = exc.response - - # Always add these headers. - # - # TODO - this isn't actually the correct way to set the vary header, - # also it's currently sub-obtimal for HTTP caching - need to sort that out. - response.headers['Allow'] = ', '.join(self.allowed_methods) - response.headers['Vary'] = 'Authenticate, Accept' - - return self.render(response) - - except: - import traceback - traceback.print_exc() - raise + # Authenticate and check request has the relevant permissions + self._check_permissions() + + # Get the appropriate handler method + if self.method.lower() in self.http_method_names: + handler = getattr(self, self.method.lower(), self.http_method_not_allowed) + else: + handler = self.http_method_not_allowed + + response_obj = handler(request, *args, **kwargs) + + # Allow return value to be either HttpResponse, Response, or an object, or None + if isinstance(response_obj, HttpResponse): + return response_obj + elif isinstance(response_obj, Response): + response = response_obj + elif response_obj is not None: + response = Response(status.HTTP_200_OK, response_obj) + else: + response = Response(status.HTTP_204_NO_CONTENT) + + # Pre-serialize filtering (eg filter complex objects into natively serializable types) + response.cleaned_content = self.filter_response(response.raw_content) + except ErrorResponse, exc: + response = exc.response + + # Always add these headers. + # + # TODO - this isn't actually the correct way to set the vary header, + # also it's currently sub-obtimal for HTTP caching - need to sort that out. + response.headers['Allow'] = ', '.join(self.allowed_methods) + response.headers['Vary'] = 'Authenticate, Accept' + + return self.render(response) class ModelView(View): From cefc6a25c239411f17740742a121a1045563d90c Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 27 May 2011 09:58:21 +0100 Subject: [PATCH 65/70] forms/models can be set on the view as well as the resource --- djangorestframework/mixins.py | 23 +++++++++++++++-------- djangorestframework/resources.py | 30 ++++++++++++++++++++++++++++-- djangorestframework/views.py | 23 +++++++++++++++++------ 3 files changed, 60 insertions(+), 16 deletions(-) diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 4d1731787..7bed131bc 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -11,7 +11,7 @@ from django.http.multipartparser import LimitBytes from djangorestframework import status from djangorestframework.parsers import FormParser, MultiPartParser -from djangorestframework.resources import Resource +from djangorestframework.resources import Resource, FormResource, ModelResource from djangorestframework.response import Response, ErrorResponse from djangorestframework.utils import as_tuple, MSIE_USER_AGENT_REGEX from djangorestframework.utils.mediatypes import is_form_media_type, order_by_precedence @@ -395,7 +395,7 @@ class ResourceMixin(object): It provides validation on the content of incoming requests, and filters the object representation into a serializable object for the response. """ - resource = Resource + resource = None @property def CONTENT(self): @@ -406,24 +406,31 @@ class ResourceMixin(object): self._content = self.validate_request(self.DATA, self.FILES) return self._content + @property + def _resource(self): + if self.resource: + return self.resource(self) + elif hasattr(self, 'model'): + return ModelResource(self) + elif hasattr(self, 'form'): + return FormResource(self) + return Resource(self) + def validate_request(self, data, files): """ Given the request *data* return the cleaned, validated content. Typically raises an :class:`response.ErrorResponse` with status code 400 (Bad Request) on failure. """ - resource = self.resource(self) - return resource.validate_request(data, files) + return self._resource.validate_request(data, files) def filter_response(self, obj): """ Given the response content, filter it into a serializable object. """ - resource = self.resource(self) - return resource.filter_response(obj) + return self._resource.filter_response(obj) def get_bound_form(self, content=None): - resource = self.resource(self) - return resource.get_bound_form(content) + return self._resource.get_bound_form(content) diff --git a/djangorestframework/resources.py b/djangorestframework/resources.py index 27d25ba9d..de8e1351c 100644 --- a/djangorestframework/resources.py +++ b/djangorestframework/resources.py @@ -165,11 +165,22 @@ class FormResource(Resource): On calling :meth:`validate_request` this validator may set a :attr:`bound_form_instance` attribute on the view, which may be used by some renderers. """ - form = None + """ The :class:`Form` class that should be used for request validation. + This can be overridden by a :attr:`form` attribute on the :class:`.View`. """ - + form = None + + def __init__(self, view): + """ + Allow a :attr:`form` attributes set on the :class:`View` to override + the :attr:`form` attribute set on the :class:`Resource`. + """ + super(FormResource, self).__init__(view) + + if getattr(view, 'form', None): + self.form = view.form def validate_request(self, data, files): @@ -314,11 +325,15 @@ class ModelResource(FormResource): """ The form class that should be used for request validation. If set to :const:`None` then the default model form validation will be used. + + This can be overridden by a :attr:`form` attribute on the :class:`.View`. """ form = None """ The model class which this resource maps to. + + This can be overridden by a :attr:`model` attribute on the :class:`.View`. """ model = None @@ -346,6 +361,17 @@ class ModelResource(FormResource): include = ('url',) + def __init__(self, view): + """ + Allow :attr:`form` and :attr:`model` attributes set on the + :class:`View` to override the :attr:`form` and :attr:`model` + attributes set on the :class:`Resource`. + """ + super(ModelResource, self).__init__(view) + + if getattr(view, 'model', None): + self.model = view.model + def validate_request(self, data, files): """ Given some content as input return some cleaned, validated content. diff --git a/djangorestframework/views.py b/djangorestframework/views.py index cdb2ba33d..1315229af 100644 --- a/djangorestframework/views.py +++ b/djangorestframework/views.py @@ -31,27 +31,38 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): Performs request deserialization, response serialization, authentication and input validation. """ - # Use the base resource by default - resource = resources.Resource + """ + The resource to use when validating requests and filtering responses, + or `None` to use default behaviour. + """ + resource = None + """ + List of renderers the resource can serialize the response with, ordered by preference. + """ renderers = ( renderers.JSONRenderer, renderers.DocumentingHTMLRenderer, renderers.DocumentingXHTMLRenderer, renderers.DocumentingPlainTextRenderer, renderers.XMLRenderer ) - """ List of renderers the resource can serialize the response with, ordered by preference.""" + """ + List of parsers the resource can parse the request with. + """ parsers = ( parsers.JSONParser, parsers.FormParser, parsers.MultiPartParser ) - """ List of parsers the resource can parse the request with.""" + """ + List of all authenticating methods to attempt. + """ authentication = ( authentication.UserLoggedInAuthenticaton, authentication.BasicAuthenticaton ) - """ List of all authenticating methods to attempt.""" + """ + List of all permissions that must be checked. + """ permissions = ( permissions.FullAnonAccess, ) - """ List of all permissions that must be checked.""" # Allow name and description for the Resource to be set explicitly, # overiding the default classname/docstring behaviour. From 9e9ae6094943d663a677b3bbe8e8af6a43744daf Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 27 May 2011 10:35:43 +0100 Subject: [PATCH 66/70] forms/models can be set on the view as well as the resource --- djangorestframework/mixins.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 7bed131bc..3c05bef02 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -410,9 +410,9 @@ class ResourceMixin(object): def _resource(self): if self.resource: return self.resource(self) - elif hasattr(self, 'model'): + elif getattr(self, 'model', None): return ModelResource(self) - elif hasattr(self, 'form'): + elif getattr(self, 'form', None): return FormResource(self) return Resource(self) From 21d2dcc2942a8e4adb3a79b0983841e5f0ab5878 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 27 May 2011 14:40:19 +0100 Subject: [PATCH 67/70] Allow .form specified on view. Allow get_form, put_form, post_form. Add .PARAMS. --- djangorestframework/mixins.py | 23 +++++++-- djangorestframework/renderers.py | 19 ++++--- djangorestframework/resources.py | 56 ++++++++++++--------- djangorestframework/templates/renderer.html | 22 ++++---- djangorestframework/views.py | 5 -- 5 files changed, 72 insertions(+), 53 deletions(-) diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 3c05bef02..11e3bb388 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -401,11 +401,22 @@ class ResourceMixin(object): def CONTENT(self): """ Returns the cleaned, validated request content. + + May raise an :class:`response.ErrorResponse` with status code 400 (Bad Request). """ if not hasattr(self, '_content'): self._content = self.validate_request(self.DATA, self.FILES) return self._content + @property + def PARAMS(self): + """ + Returns the cleaned, validated query parameters. + + May raise an :class:`response.ErrorResponse` with status code 400 (Bad Request). + """ + return self.validate_request(self.request.GET) + @property def _resource(self): if self.resource: @@ -414,12 +425,14 @@ class ResourceMixin(object): return ModelResource(self) elif getattr(self, 'form', None): return FormResource(self) + elif getattr(self, '%s_form' % self.method.lower(), None): + return FormResource(self) return Resource(self) - def validate_request(self, data, files): + def validate_request(self, data, files=None): """ - Given the request *data* return the cleaned, validated content. - Typically raises an :class:`response.ErrorResponse` with status code 400 (Bad Request) on failure. + Given the request *data* and optional *files*, return the cleaned, validated content. + May raise an :class:`response.ErrorResponse` with status code 400 (Bad Request) on failure. """ return self._resource.validate_request(data, files) @@ -429,8 +442,8 @@ class ResourceMixin(object): """ return self._resource.filter_response(obj) - def get_bound_form(self, content=None): - return self._resource.get_bound_form(content) + def get_bound_form(self, content=None, method=None): + return self._resource.get_bound_form(content, method=method) diff --git a/djangorestframework/renderers.py b/djangorestframework/renderers.py index 245bfdfea..9834ba5e5 100644 --- a/djangorestframework/renderers.py +++ b/djangorestframework/renderers.py @@ -172,7 +172,7 @@ class DocumentingTemplateRenderer(BaseRenderer): return content - def _get_form_instance(self, view): + def _get_form_instance(self, view, method): """ Get a form, possibly bound to either the input or output data. In the absence on of the Resource having an associated form then @@ -180,22 +180,24 @@ class DocumentingTemplateRenderer(BaseRenderer): """ # Get the form instance if we have one bound to the input - form_instance = getattr(view, 'bound_form_instance', None) + form_instance = None + if method == view.method.lower(): + form_instance = getattr(view, 'bound_form_instance', None) if not form_instance and hasattr(view, 'get_bound_form'): # Otherwise if we have a response that is valid against the form then use that if view.response.has_content_body: try: - form_instance = view.get_bound_form(view.response.cleaned_content) + form_instance = view.get_bound_form(view.response.cleaned_content, method=method) 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 = view.get_bound_form() + form_instance = view.get_bound_form(method=method) except: pass @@ -250,7 +252,9 @@ class DocumentingTemplateRenderer(BaseRenderer): needed to self-document the response to this request. """ content = self._get_content(self.view, self.view.request, obj, media_type) - form_instance = self._get_form_instance(self.view) + + put_form_instance = self._get_form_instance(self.view, 'put') + post_form_instance = self._get_form_instance(self.view, 'post') if url_resolves(settings.LOGIN_URL) and url_resolves(settings.LOGOUT_URL): login_url = "%s?next=%s" % (settings.LOGIN_URL, quote_plus(self.view.request.path)) @@ -282,7 +286,8 @@ class DocumentingTemplateRenderer(BaseRenderer): 'markeddown': markeddown, 'breadcrumblist': breadcrumb_list, 'available_media_types': self.view._rendered_media_types, - 'form': form_instance, + 'put_form': put_form_instance, + 'post_form': post_form_instance, 'login_url': login_url, 'logout_url': logout_url, 'ACCEPT_PARAM': getattr(self.view, '_ACCEPT_QUERY_PARAM', None), diff --git a/djangorestframework/resources.py b/djangorestframework/resources.py index de8e1351c..1ac6eeac3 100644 --- a/djangorestframework/resources.py +++ b/djangorestframework/resources.py @@ -122,7 +122,7 @@ class BaseResource(object): def __init__(self, view): self.view = view - def validate_request(self, data, files): + def validate_request(self, data, files=None): """ Given the request content return the cleaned, validated content. Typically raises a :exc:`response.ErrorResponse` with status code 400 (Bad Request) on failure. @@ -168,22 +168,12 @@ class FormResource(Resource): """ The :class:`Form` class that should be used for request validation. - This can be overridden by a :attr:`form` attribute on the :class:`.View`. + This can be overridden by a :attr:`form` attribute on the :class:`views.View`. """ form = None - def __init__(self, view): - """ - Allow a :attr:`form` attributes set on the :class:`View` to override - the :attr:`form` attribute set on the :class:`Resource`. - """ - super(FormResource, self).__init__(view) - if getattr(view, 'form', None): - self.form = view.form - - - def validate_request(self, data, files): + def validate_request(self, data, files=None): """ Given some content as input return some cleaned, validated content. Raises a :exc:`response.ErrorResponse` with status code 400 (Bad Request) on failure. @@ -285,18 +275,34 @@ class FormResource(Resource): raise ErrorResponse(400, detail) - def get_bound_form(self, data=None, files=None): + def get_bound_form(self, data=None, files=None, method=None): """ Given some content return a Django form bound to that content. If form validation is turned off (:attr:`form` class attribute is :const:`None`) then returns :const:`None`. """ - if not self.form: + + # A form on the view overrides a form on the resource. + form = getattr(self.view, 'form', self.form) + + # Use the requested method or determine the request method + if method is None and hasattr(self.view, 'request') and hasattr(self.view, 'method'): + method = self.view.method + elif method is None and hasattr(self.view, 'request'): + method = self.view.request.method + + # A method form on the view or resource overrides the general case. + # Method forms are attributes like `get_form` `post_form` `put_form`. + if method: + form = getattr(self, '%s_form' % method.lower(), form) + form = getattr(self.view, '%s_form' % method.lower(), form) + + if not form: return None if data is not None: - return self.form(data, files) + return form(data, files) - return self.form() + return form() @@ -326,14 +332,14 @@ class ModelResource(FormResource): The form class that should be used for request validation. If set to :const:`None` then the default model form validation will be used. - This can be overridden by a :attr:`form` attribute on the :class:`.View`. + This can be overridden by a :attr:`form` attribute on the :class:`views.View`. """ form = None """ The model class which this resource maps to. - This can be overridden by a :attr:`model` attribute on the :class:`.View`. + This can be overridden by a :attr:`model` attribute on the :class:`views.View`. """ model = None @@ -372,7 +378,7 @@ class ModelResource(FormResource): if getattr(view, 'model', None): self.model = view.model - def validate_request(self, data, files): + def validate_request(self, data, files=None): """ Given some content as input return some cleaned, validated content. Raises a :exc:`response.ErrorResponse` with status code 400 (Bad Request) on failure. @@ -389,7 +395,7 @@ class ModelResource(FormResource): return self._validate(data, files, allowed_extra_fields=self._property_fields_set) - def get_bound_form(self, data=None, files=None): + def get_bound_form(self, data=None, files=None, method=None): """ Given some content return a ``Form`` instance bound to that content. @@ -397,9 +403,11 @@ class ModelResource(FormResource): to create the Form, otherwise the model will be used to create a ModelForm. """ - if self.form: - # Use explict Form - return super(ModelResource, self).get_bound_form(data, files) + form = super(ModelResource, self).get_bound_form(data, files, method=method) + + # Use an explict Form if it exists + if form: + return form elif self.model: # Fall back to ModelForm which we create on the fly diff --git a/djangorestframework/templates/renderer.html b/djangorestframework/templates/renderer.html index 3010d7125..09b17df76 100644 --- a/djangorestframework/templates/renderer.html +++ b/djangorestframework/templates/renderer.html @@ -58,19 +58,16 @@ {% endif %} - {% comment %} *** Only display the POST/PUT/DELETE forms if we have a bound form, and if method *** - *** tunneling via POST forms is enabled. *** - *** (We could display only the POST form if method tunneling is disabled, but I think *** - *** the user experience would be confusing, so we simply turn all forms off. *** {% endcomment %} - - {% if METHOD_PARAM and form %} + {# Only display the POST/PUT/DELETE forms if method tunneling via POST forms is enabled. #} + {% if METHOD_PARAM %} + {% if 'POST' in view.allowed_methods %} -
+

POST {{ name }}

{% csrf_token %} - {{ form.non_field_errors }} - {% for field in form %} + {{ post_form.non_field_errors }} + {% for field in post_form %}
{{ field.label_tag }} {{ field }} @@ -86,13 +83,13 @@ {% endif %} {% if 'PUT' in view.allowed_methods %} - +

PUT {{ name }}

{% csrf_token %} - {{ form.non_field_errors }} - {% for field in form %} + {{ put_form.non_field_errors }} + {% for field in put_form %}
{{ field.label_tag }} {{ field }} @@ -119,6 +116,7 @@
{% endif %} + {% endif %}

diff --git a/djangorestframework/views.py b/djangorestframework/views.py index 1315229af..5b3cc8553 100644 --- a/djangorestframework/views.py +++ b/djangorestframework/views.py @@ -64,11 +64,6 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): """ permissions = ( permissions.FullAnonAccess, ) - # 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 renderers. - name = None - description = None @classmethod def as_view(cls, **initkwargs): From e2052f858b271d991f1ed2eea4cbaac0a00161a8 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 27 May 2011 16:11:06 +0100 Subject: [PATCH 68/70] Fixes github #17 --- djangorestframework/templatetags/urlize_quoted_links.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/djangorestframework/templatetags/urlize_quoted_links.py b/djangorestframework/templatetags/urlize_quoted_links.py index eea424a41..bd3cbcf59 100644 --- a/djangorestframework/templatetags/urlize_quoted_links.py +++ b/djangorestframework/templatetags/urlize_quoted_links.py @@ -63,11 +63,11 @@ def urlize_quoted_links(text, trim_url_limit=None, nofollow=True, autoescape=Tru # Make URL we want to point to. url = None if middle.startswith('http://') or middle.startswith('https://'): - url = urlquote(middle, safe='/&=:;#?+*') + url = middle elif middle.startswith('www.') or ('@' not in middle and \ middle and middle[0] in string.ascii_letters + string.digits and \ (middle.endswith('.org') or middle.endswith('.net') or middle.endswith('.com'))): - url = urlquote('http://%s' % middle, safe='/&=:;#?+*') + url = 'http://%s' % middle elif '@' in middle and not ':' in middle and simple_email_re.match(middle): url = 'mailto:%s' % middle nofollow_attr = '' From bb5b0eb80a3f57b399f437d81a945467a777ba8a Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 27 May 2011 16:12:03 +0100 Subject: [PATCH 69/70] strip _accept when validating --- djangorestframework/resources.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangorestframework/resources.py b/djangorestframework/resources.py index 1ac6eeac3..adf5c1c3d 100644 --- a/djangorestframework/resources.py +++ b/djangorestframework/resources.py @@ -223,7 +223,7 @@ class FormResource(Resource): # 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) - unknown_fields = unknown_fields - set(('csrfmiddlewaretoken',)) + unknown_fields = unknown_fields - set(('csrfmiddlewaretoken', '_accept')) # TODO: Ugh. # Check using both regular validation, and our stricter no additional fields rule if bound_form.is_valid() and not unknown_fields: From fc1640de75511006e89f033c9270ec91a9f1e4d4 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 31 May 2011 14:40:30 +0100 Subject: [PATCH 70/70] Fix query parameters getting lost --- djangorestframework/templates/renderer.html | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/djangorestframework/templates/renderer.html b/djangorestframework/templates/renderer.html index 09b17df76..94748d28a 100644 --- a/djangorestframework/templates/renderer.html +++ b/djangorestframework/templates/renderer.html @@ -47,10 +47,10 @@

GET {{ name }}

- GET + GET {% for media_type in available_media_types %} {% with ACCEPT_PARAM|add:"="|add:media_type as param %} - [{{ media_type }}] + [{{ media_type }}] {% endwith %} {% endfor %}
@@ -62,7 +62,7 @@ {% if METHOD_PARAM %} {% if 'POST' in view.allowed_methods %} -
+

POST {{ name }}

{% csrf_token %} @@ -83,7 +83,7 @@ {% endif %} {% if 'PUT' in view.allowed_methods %} - +

PUT {{ name }}

@@ -105,7 +105,7 @@ {% endif %} {% if 'DELETE' in view.allowed_methods %} - +

DELETE {{ name }}

{% csrf_token %}