diff --git a/djangorestframework/authentication.py b/djangorestframework/authentication.py index f46a9c460..3ddd9e45f 100644 --- a/djangorestframework/authentication.py +++ b/djangorestframework/authentication.py @@ -1,10 +1,7 @@ """ The :mod:`authentication` module provides a set of pluggable authentication classes. -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 :class:`authentication` classes. +Authentication behavior is provided by mixing the :class:`mixins.RequestMixin` class into a :class:`View` class. """ from django.contrib.auth import authenticate @@ -23,12 +20,6 @@ class BaseAuthentication(object): All authentication classes should extend BaseAuthentication. """ - def __init__(self, view): - """ - :class:`Authentication` classes are always passed the current view on creation. - """ - self.view = view - def authenticate(self, request): """ Authenticate the :obj:`request` and return a :obj:`User` or :const:`None`. [*]_ @@ -87,14 +78,14 @@ class UserLoggedInAuthentication(BaseAuthentication): Returns a :obj:`User` if the request session currently has a logged in user. Otherwise returns :const:`None`. """ - self.view.DATA # Make sure our generic parsing runs first + user = getattr(request._request, 'user', None) - if getattr(request, 'user', None) and request.user.is_active: + if user and user.is_active: # Enforce CSRF validation for session based authentication. resp = CsrfViewMiddleware().process_view(request, None, (), {}) if resp is None: # csrf passed - return request.user + return user return None diff --git a/djangorestframework/mixins.py b/djangorestframework/mixins.py index 6c8f8179d..3142b093b 100644 --- a/djangorestframework/mixins.py +++ b/djangorestframework/mixins.py @@ -3,27 +3,22 @@ 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 from django.core.paginator import Paginator from django.db.models.fields.related import ForeignKey -from django.http import HttpResponse from urlobject import URLObject from djangorestframework import status from djangorestframework.renderers import BaseRenderer from djangorestframework.resources import Resource, FormResource, ModelResource -from djangorestframework.response import Response, ErrorResponse -from djangorestframework.utils import as_tuple, MSIE_USER_AGENT_REGEX -from djangorestframework.utils.mediatypes import is_form_media_type, order_by_precedence - -from StringIO import StringIO +from djangorestframework.response import Response, ImmediateResponse +from djangorestframework.request import Request __all__ = ( # Base behavior mixins 'RequestMixin', 'ResponseMixin', - 'AuthMixin', + 'PermissionsMixin', 'ResourceMixin', # Model behavior mixins 'ReadModelMixin', @@ -39,150 +34,21 @@ __all__ = ( class RequestMixin(object): """ - `Mixin` class to provide request parsing behavior. + `Mixin` class enabling the use of :class:`request.Request` in your views. """ - _USE_FORM_OVERLOADING = True - _METHOD_PARAM = '_method' - _CONTENTTYPE_PARAM = '_content_type' - _CONTENT_PARAM = '_content' - - parsers = () + request_class = Request """ - The set of request parsers that the view can handle. - - Should be a tuple/list of classes as described in the :mod:`parsers` module. + The class to use as a wrapper for the original request object. """ - @property - def method(self): + def create_request(self, request): """ - Returns the HTTP method. - - 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. + Creates and returns an instance of :class:`request.Request`. + This new instance wraps the `request` passed as a parameter, and use + the parsers set on the view. """ - if not hasattr(self, '_method'): - self._load_method_and_content_type() - return self._method - - @property - 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() - return self._content_type - - @property - def DATA(self): - """ - 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() - return self._data - - @property - def FILES(self): - """ - 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() - return self._files - - def _load_data_and_files(self): - """ - Parse the request content into self.DATA and self.FILES. - """ - if not hasattr(self, '_content_type'): - self._load_method_and_content_type() - - 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. - """ - 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): - """ - Returns an object that may be used to stream the request content. - """ - request = self.request - - 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. - # NOTE: 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) - - def _perform_form_overloading(self): - """ - 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 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.copy() - self._files = self.request.FILES - - # Method overloading - change the method and remove the param from the content. - if self._METHOD_PARAM in data: - # NOTE: unlike `get`, `pop` on a `QueryDict` seems to return a list of values. - self._method = self._data.pop(self._METHOD_PARAM)[0].upper() - - # Content overloading - modify the content type, and re-parse. - if self._CONTENT_PARAM in data and self._CONTENTTYPE_PARAM in data: - self._content_type = self._data.pop(self._CONTENTTYPE_PARAM)[0] - stream = StringIO(self._data.pop(self._CONTENT_PARAM)[0]) - (self._data, self._files) = self._parse(stream, self._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, None) - - parsers = as_tuple(self.parsers) - - for parser_cls in parsers: - parser = parser_cls(self) - if parser.can_handle_request(content_type): - return parser.parse(stream) - - raise ErrorResponse(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, - {'error': 'Unsupported media type in request \'%s\'.' % - content_type}) + return self.request_class(request, parsers=self.parsers, authentication=self.authentication) @property def _parsed_media_types(self): @@ -203,177 +69,63 @@ class RequestMixin(object): class ResponseMixin(object): """ - 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. - Ignores Accept headers from Internet Explorer user agents and uses a sensible browser Accept header instead. + `Mixin` class enabling the use of :class:`response.Response` in your views. """ - _ACCEPT_QUERY_PARAM = '_accept' # Allow override of Accept header in URL query params - _IGNORE_IE_ACCEPT_HEADER = True - renderers = () """ The set of response renderers that the view can handle. - Should be a tuple/list of classes as described in the :mod:`renderers` module. """ - def get_renderers(self): - """ - Return an iterable of available renderers. Override if you want to change - this list at runtime, say depending on what settings you have enabled. - """ - return self.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): - """ - Takes a :obj:`Response` object and returns an :obj:`HttpResponse`. - """ - self.response = response - - try: - 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 - if response.has_content_body: - content = renderer.render(response.cleaned_content, media_type) - else: - content = renderer.render() - - # Build the HTTP Response - resp = HttpResponse(content, mimetype=response.media_type, status=response.status) - for (key, val) in response.headers.items(): - resp[key] = val - - return resp - - def _determine_renderer(self, request): - """ - 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 - """ - - 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._IGNORE_IE_ACCEPT_HEADER and - 'HTTP_USER_AGENT' in request.META 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 'HTTP_ACCEPT' in request.META: - # Use standard HTTP Accept negotiation - accept_list = [token.strip() for token in request.META['HTTP_ACCEPT'].split(',')] - else: - # No accept header specified - accept_list = ['*/*'] - - # 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 :) - # Worst case is we're looping over len(accept_list) * len(self.renderers) - renderers = [renderer_cls(self) for renderer_cls in self.get_renderers()] - - for accepted_media_type_lst in order_by_precedence(accept_list): - for renderer in renderers: - for accepted_media_type in accepted_media_type_lst: - if renderer.can_handle_response(accepted_media_type): - return renderer, accepted_media_type - - # No acceptable renderers were found - raise ErrorResponse(status.HTTP_406_NOT_ACCEPTABLE, - {'detail': 'Could not satisfy the client\'s Accept header', - 'available_types': self._rendered_media_types}) - @property def _rendered_media_types(self): """ - Return an list of all the media types that this view can render. + Return an list of all the media types that this response can render. """ return [renderer.media_type for renderer in self.renderers] @property def _rendered_formats(self): """ - Return a list of all the formats that this view can render. + Return a list of all the formats that this response can render. """ return [renderer.format for renderer in self.renderers] @property def _default_renderer(self): """ - Return the view's default renderer class. + Return the response's default renderer class. """ return self.renderers[0] -########## Auth Mixin ########## +########## Permissions Mixin ########## -class AuthMixin(object): +class PermissionsMixin(object): """ - Simple :class:`mixin` class to add authentication and permission checking to a :class:`View` class. + Simple :class:`mixin` class to add permission checking to a :class:`View` class. """ - authentication = () - """ - The set of authentication types that this view can handle. - - Should be a tuple/list of classes as described in the :mod:`authentication` module. - """ - - permissions = () + permissions_classes = () """ The set of permissions that will be enforced on this view. Should be a tuple/list of classes as described in the :mod:`permissions` module. """ - @property - def user(self): + def get_permissions(self): """ - Returns the :obj:`user` for the current request, as determined by the set of - :class:`authentication` classes applied to the :class:`View`. + Instantiates and returns the list of permissions that this view requires. """ - 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) - user = authentication.authenticate(self.request) - if user: - return user - return AnonymousUser() + return [p(self) for p in self.permissions_classes] # TODO: wrap this behavior around dispatch() - def _check_permissions(self): + def check_permissions(self, user): """ - Check user permissions and either raise an ``ErrorResponse`` or return. + Check user permissions and either raise an ``ImmediateResponse`` or return. """ - user = self.user - for permission_cls in self.permissions: - permission = permission_cls(self) + for permission in self.get_permissions(): permission.check_permission(user) @@ -397,10 +149,10 @@ class ResourceMixin(object): """ Returns the cleaned, validated request content. - May raise an :class:`response.ErrorResponse` with status code 400 (Bad Request). + May raise an :class:`response.ImmediateResponse` with status code 400 (Bad Request). """ if not hasattr(self, '_content'): - self._content = self.validate_request(self.DATA, self.FILES) + self._content = self.validate_request(self.request.DATA, self.request.FILES) return self._content @property @@ -408,7 +160,7 @@ class ResourceMixin(object): """ Returns the cleaned, validated query parameters. - May raise an :class:`response.ErrorResponse` with status code 400 (Bad Request). + May raise an :class:`response.ImmediateResponse` with status code 400 (Bad Request). """ return self.validate_request(self.request.GET) @@ -420,14 +172,14 @@ class ResourceMixin(object): return ModelResource(self) elif getattr(self, 'form', None): return FormResource(self) - elif getattr(self, '%s_form' % self.method.lower(), None): + elif getattr(self, '%s_form' % self.request.method.lower(), None): return FormResource(self) return Resource(self) def validate_request(self, data, files=None): """ 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. + May raise an :class:`response.ImmediateResponse` with status code 400 (Bad Request) on failure. """ return self._resource.validate_request(data, files) @@ -534,9 +286,9 @@ class ReadModelMixin(ModelMixin): try: self.model_instance = self.get_instance(**query_kwargs) except model.DoesNotExist: - raise ErrorResponse(status.HTTP_404_NOT_FOUND) + raise ImmediateResponse(status=status.HTTP_404_NOT_FOUND) - return self.model_instance + return Response(self.model_instance) class CreateModelMixin(ModelMixin): @@ -573,10 +325,12 @@ class CreateModelMixin(ModelMixin): data[m2m_data[fieldname][0]] = related_item manager.through(**data).save() - headers = {} + response = Response(instance, status=status.HTTP_201_CREATED) + + # Set headers if hasattr(self.resource, 'url'): - headers['Location'] = self.resource(self).url(instance) - return Response(status.HTTP_201_CREATED, instance, headers) + response['Location'] = self.resource(self).url(instance) + return response class UpdateModelMixin(ModelMixin): @@ -597,7 +351,7 @@ class UpdateModelMixin(ModelMixin): except model.DoesNotExist: self.model_instance = model(**self.get_instance_data(model, self.CONTENT, *args, **kwargs)) self.model_instance.save() - return self.model_instance + return Response(self.model_instance) class DeleteModelMixin(ModelMixin): @@ -611,10 +365,10 @@ class DeleteModelMixin(ModelMixin): try: instance = self.get_instance(**query_kwargs) except model.DoesNotExist: - raise ErrorResponse(status.HTTP_404_NOT_FOUND, None, {}) + raise ImmediateResponse(status=status.HTTP_404_NOT_FOUND) instance.delete() - return + return Response() class ListModelMixin(ModelMixin): @@ -631,7 +385,7 @@ class ListModelMixin(ModelMixin): if ordering: queryset = queryset.order_by(*ordering) - return queryset + return Response(queryset) ########## Pagination Mixins ########## @@ -710,7 +464,7 @@ class PaginatorMixin(object): """ # We don't want to paginate responses for anything other than GET requests - if self.method.upper() != 'GET': + if self.request.method.upper() != 'GET': return self._resource.filter_response(obj) paginator = Paginator(obj, self.get_limit()) @@ -718,12 +472,14 @@ class PaginatorMixin(object): try: page_num = int(self.request.GET.get('page', '1')) except ValueError: - raise ErrorResponse(status.HTTP_404_NOT_FOUND, - {'detail': 'That page contains no results'}) + raise ImmediateResponse( + {'detail': 'That page contains no results'}, + status=status.HTTP_404_NOT_FOUND) if page_num not in paginator.page_range: - raise ErrorResponse(status.HTTP_404_NOT_FOUND, - {'detail': 'That page contains no results'}) + raise ImmediateResponse( + {'detail': 'That page contains no results'}, + status=status.HTTP_404_NOT_FOUND) page = paginator.page(page_num) diff --git a/djangorestframework/parsers.py b/djangorestframework/parsers.py index a19582b7c..0eb72f38a 100644 --- a/djangorestframework/parsers.py +++ b/djangorestframework/parsers.py @@ -17,7 +17,7 @@ from django.http.multipartparser import MultiPartParserError from django.utils import simplejson as json from djangorestframework import status from djangorestframework.compat import yaml -from djangorestframework.response import ErrorResponse +from djangorestframework.response import ImmediateResponse from djangorestframework.utils.mediatypes import media_type_matches from xml.etree import ElementTree as ET from djangorestframework.compat import ETParseError @@ -45,13 +45,6 @@ class BaseParser(object): 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 :obj:`View` object. - """ - self.view = view - def can_handle_request(self, content_type): """ Returns :const:`True` if this parser is able to deal with the given *content_type*. @@ -65,12 +58,12 @@ class BaseParser(object): """ return media_type_matches(self.media_type, content_type) - def parse(self, stream): + def parse(self, stream, meta, upload_handlers): """ 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.") + raise NotImplementedError(".parse() Must be overridden to be implemented.") class JSONParser(BaseParser): @@ -80,7 +73,7 @@ class JSONParser(BaseParser): media_type = 'application/json' - def parse(self, stream): + def parse(self, stream, meta, upload_handlers): """ Returns a 2-tuple of `(data, files)`. @@ -90,8 +83,9 @@ class JSONParser(BaseParser): try: return (json.load(stream), None) except ValueError, exc: - raise ErrorResponse(status.HTTP_400_BAD_REQUEST, - {'detail': 'JSON parse error - %s' % unicode(exc)}) + raise ImmediateResponse( + {'detail': 'JSON parse error - %s' % unicode(exc)}, + status=status.HTTP_400_BAD_REQUEST) class YAMLParser(BaseParser): @@ -101,7 +95,7 @@ class YAMLParser(BaseParser): media_type = 'application/yaml' - def parse(self, stream): + def parse(self, stream, meta, upload_handlers): """ Returns a 2-tuple of `(data, files)`. @@ -111,8 +105,9 @@ class YAMLParser(BaseParser): try: return (yaml.safe_load(stream), None) except (ValueError, yaml.parser.ParserError), exc: - content = {'detail': 'YAML parse error - %s' % unicode(exc)} - raise ErrorResponse(status.HTTP_400_BAD_REQUEST, content) + raise ImmediateResponse( + {'detail': 'YAML parse error - %s' % unicode(exc)}, + status=status.HTTP_400_BAD_REQUEST) class PlainTextParser(BaseParser): @@ -122,7 +117,7 @@ class PlainTextParser(BaseParser): media_type = 'text/plain' - def parse(self, stream): + def parse(self, stream, meta, upload_handlers): """ Returns a 2-tuple of `(data, files)`. @@ -139,7 +134,7 @@ class FormParser(BaseParser): media_type = 'application/x-www-form-urlencoded' - def parse(self, stream): + def parse(self, stream, meta, upload_handlers): """ Returns a 2-tuple of `(data, files)`. @@ -157,20 +152,20 @@ class MultiPartParser(BaseParser): media_type = 'multipart/form-data' - def parse(self, stream): + def parse(self, stream, meta, upload_handlers): """ Returns a 2-tuple of `(data, files)`. `data` will be a :class:`QueryDict` containing all the form parameters. `files` will be a :class:`QueryDict` containing all the form files. """ - upload_handlers = self.view.request._get_upload_handlers() try: - django_parser = DjangoMultiPartParser(self.view.request.META, stream, upload_handlers) - return django_parser.parse() + parser = DjangoMultiPartParser(meta, stream, upload_handlers) + return parser.parse() except MultiPartParserError, exc: - raise ErrorResponse(status.HTTP_400_BAD_REQUEST, - {'detail': 'multipart parse error - %s' % unicode(exc)}) + raise ImmediateResponse( + {'detail': 'multipart parse error - %s' % unicode(exc)}, + status=status.HTTP_400_BAD_REQUEST) class XMLParser(BaseParser): @@ -180,7 +175,7 @@ class XMLParser(BaseParser): media_type = 'application/xml' - def parse(self, stream): + def parse(self, stream, meta, upload_handlers): """ Returns a 2-tuple of `(data, files)`. @@ -188,10 +183,10 @@ class XMLParser(BaseParser): `files` will always be `None`. """ try: - tree = ET.parse(stream) + tree = ET.parse(stream) except (ExpatError, ETParseError, ValueError), exc: - content = {'detail': 'XML parse error - %s' % unicode(exc)} - raise ErrorResponse(status.HTTP_400_BAD_REQUEST, content) + content = {'detail': 'XML parse error - %s' % unicode(exc)} + raise ImmediateResponse(content, status=status.HTTP_400_BAD_REQUEST) data = self._xml_convert(tree.getroot()) return (data, None) @@ -255,4 +250,3 @@ if yaml: DEFAULT_PARSERS += (YAMLParser, ) else: YAMLParser = None - diff --git a/djangorestframework/permissions.py b/djangorestframework/permissions.py index 03d78c2ea..4a2b1b00d 100644 --- a/djangorestframework/permissions.py +++ b/djangorestframework/permissions.py @@ -1,12 +1,13 @@ """ -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 permission -class to your view by setting your View's :attr:`permissions` class attribute. +The :mod:`permissions` module bundles a set of permission classes that are used +for checking if a request passes a certain set of constraints. + +Permission behavior is provided by mixing the :class:`mixins.PermissionsMixin` class into a :class:`View` class. """ from django.core.cache import cache from djangorestframework import status -from djangorestframework.response import ErrorResponse +from djangorestframework.response import ImmediateResponse import time __all__ = ( @@ -23,14 +24,14 @@ __all__ = ( SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'] -_403_FORBIDDEN_RESPONSE = ErrorResponse( - status.HTTP_403_FORBIDDEN, +_403_FORBIDDEN_RESPONSE = ImmediateResponse( {'detail': 'You do not have permission to access this resource. ' + - 'You may need to login or otherwise authenticate the request.'}) + 'You may need to login or otherwise authenticate the request.'}, + status=status.HTTP_403_FORBIDDEN) -_503_SERVICE_UNAVAILABLE = ErrorResponse( - status.HTTP_503_SERVICE_UNAVAILABLE, - {'detail': 'request was throttled'}) +_503_SERVICE_UNAVAILABLE = ImmediateResponse( + {'detail': 'request was throttled'}, + status=status.HTTP_503_SERVICE_UNAVAILABLE) class BasePermission(object): @@ -45,7 +46,7 @@ class BasePermission(object): def check_permission(self, auth): """ - Should simply return, or raise an :exc:`response.ErrorResponse`. + Should simply return, or raise an :exc:`response.ImmediateResponse`. """ pass @@ -126,7 +127,7 @@ class DjangoModelPermissions(BasePermission): try: return [perm % kwargs for perm in self.perms_map[method]] except KeyError: - ErrorResponse(status.HTTP_405_METHOD_NOT_ALLOWED) + ImmediateResponse(status.HTTP_405_METHOD_NOT_ALLOWED) def check_permission(self, user): method = self.view.method @@ -164,7 +165,7 @@ class BaseThrottle(BasePermission): def check_permission(self, auth): """ Check the throttling. - Return `None` or raise an :exc:`.ErrorResponse`. + Return `None` or raise an :exc:`.ImmediateResponse`. """ num, period = getattr(self.view, self.attr_name, self.default).split('/') self.num_requests = int(num) @@ -200,7 +201,7 @@ class BaseThrottle(BasePermission): self.history.insert(0, self.now) cache.set(self.key, self.history, self.duration) header = 'status=SUCCESS; next=%s sec' % self.next() - self.view.add_header('X-Throttle', header) + self.view.headers['X-Throttle'] = header def throttle_failure(self): """ @@ -208,7 +209,7 @@ class BaseThrottle(BasePermission): Raises a '503 service unavailable' response. """ header = 'status=FAILURE; next=%s sec' % self.next() - self.view.add_header('X-Throttle', header) + self.view.headers['X-Throttle'] = header raise _503_SERVICE_UNAVAILABLE def next(self): diff --git a/djangorestframework/renderers.py b/djangorestframework/renderers.py index d9aa4028e..8d1030254 100644 --- a/djangorestframework/renderers.py +++ b/djangorestframework/renderers.py @@ -6,20 +6,18 @@ by serializing the output along with documentation regarding the View, output st 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 from django.core.serializers.json import DateTimeAwareJSONEncoder from django.template import RequestContext, loader from django.utils import simplejson as json - from djangorestframework.compat import yaml -from djangorestframework.utils import dict2xml, url_resolves +from djangorestframework.utils import dict2xml from djangorestframework.utils.breadcrumbs import get_breadcrumbs from djangorestframework.utils.mediatypes import get_media_type_params, add_media_type_param, media_type_matches from djangorestframework import VERSION import string -from urllib import quote_plus + __all__ = ( 'BaseRenderer', @@ -45,7 +43,7 @@ class BaseRenderer(object): media_type = None format = None - def __init__(self, view): + def __init__(self, view=None): self.view = view def can_handle_response(self, accept): @@ -60,9 +58,13 @@ class BaseRenderer(object): This may be overridden to provide for other behavior, but typically you'll instead want to just set the :attr:`media_type` attribute on the class. """ - format = self.view.kwargs.get(self._FORMAT_QUERY_PARAM, None) - if format is None: + # TODO: format overriding must go out of here + format = None + if self.view is not None: + format = self.view.kwargs.get(self._FORMAT_QUERY_PARAM, None) + if format is None and self.view is not None: format = self.view.request.GET.get(self._FORMAT_QUERY_PARAM, None) + if format is not None: return format == self.format return media_type_matches(self.media_type, accept) @@ -211,7 +213,8 @@ class DocumentingTemplateRenderer(BaseRenderer): """ # Find the first valid renderer and render the content. (Don't use another documenting renderer.) - renderers = [renderer for renderer in view.renderers if not issubclass(renderer, DocumentingTemplateRenderer)] + renderers = [renderer for renderer in view.renderers + if not issubclass(renderer, DocumentingTemplateRenderer)] if not renderers: return '[No renderers were found]' @@ -265,12 +268,12 @@ 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.request, '_USE_FORM_OVERLOADING', False): return None # NB. http://jacobian.org/writing/dynamic-form-generation/ class GenericContentForm(forms.Form): - def __init__(self, view): + def __init__(self, view, request): """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.""" @@ -279,18 +282,18 @@ 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', + self.fields[request._CONTENTTYPE_PARAM] = forms.ChoiceField(label='Content Type', choices=contenttype_choices, initial=initial_contenttype) - self.fields[view._CONTENT_PARAM] = forms.CharField(label='Content', + self.fields[request._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.request._CONTENTTYPE_PARAM is None or self.view.request._CONTENT_PARAM is None: return None # Okey doke, let's do it - return GenericContentForm(view) + return GenericContentForm(view, view.request) def get_name(self): try: @@ -319,13 +322,6 @@ class DocumentingTemplateRenderer(BaseRenderer): 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)) - logout_url = "%s?next=%s" % (settings.LOGOUT_URL, quote_plus(self.view.request.path)) - else: - login_url = None - logout_url = None - name = self.get_name() description = self.get_description() @@ -341,6 +337,7 @@ class DocumentingTemplateRenderer(BaseRenderer): 'name': name, 'version': VERSION, 'breadcrumblist': breadcrumb_list, + 'allowed_methods': self.view.allowed_methods, 'available_formats': self.view._rendered_formats, 'put_form': put_form_instance, 'post_form': post_form_instance, @@ -353,8 +350,8 @@ class DocumentingTemplateRenderer(BaseRenderer): # 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 + if self.view.response.status_code == 204: + self.view.response.status_code = 200 return ret diff --git a/djangorestframework/request.py b/djangorestframework/request.py new file mode 100644 index 000000000..82aed1e0c --- /dev/null +++ b/djangorestframework/request.py @@ -0,0 +1,242 @@ +""" +The :mod:`request` module provides a :class:`Request` class used to wrap the standard `request` +object received in all the views. + +The wrapped request then offers a richer API, in particular : + + - content automatically parsed according to `Content-Type` header, + and available as :meth:`.DATA` + - full support of PUT method, including support for file uploads + - form overloading of HTTP method, content type and content +""" +from StringIO import StringIO + +from django.contrib.auth.models import AnonymousUser + +from djangorestframework import status +from djangorestframework.utils.mediatypes import is_form_media_type + + +__all__ = ('Request',) + + +class Empty: + pass + + +def _hasattr(obj, name): + return not getattr(obj, name) is Empty + + +class Request(object): + """ + Wrapper allowing to enhance a standard `HttpRequest` instance. + + Kwargs: + - request(HttpRequest). The original request instance. + - parsers(list/tuple). The parsers to use for parsing the request content. + - authentications(list/tuple). The authentications used to try authenticating the request's user. + """ + + _USE_FORM_OVERLOADING = True + _METHOD_PARAM = '_method' + _CONTENTTYPE_PARAM = '_content_type' + _CONTENT_PARAM = '_content' + + def __init__(self, request=None, parsers=None, authentication=None): + self._request = request + self.parsers = parsers or () + self.authentication = authentication or () + self._data = Empty + self._files = Empty + self._method = Empty + self._content_type = Empty + self._stream = Empty + + def get_parsers(self): + """ + Instantiates and returns the list of parsers the request will use. + """ + return [parser() for parser in self.parsers] + + def get_authentications(self): + """ + Instantiates and returns the list of parsers the request will use. + """ + return [authentication() for authentication in self.authentication] + + @property + def method(self): + """ + Returns the HTTP method. + + This 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() + return self._method + + @property + 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() + return self._content_type + + @property + def stream(self): + """ + Returns an object that may be used to stream the request content. + """ + if not _hasattr(self, '_stream'): + self._load_stream() + return self._stream + + @property + def DATA(self): + """ + 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() + return self._data + + @property + def FILES(self): + """ + 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() + return self._files + + @property + def user(self): + """ + Returns the :obj:`user` for the current request, authenticated + with the set of :class:`authentication` instances applied to the :class:`Request`. + """ + if not hasattr(self, '_user'): + self._user = self._authenticate() + return self._user + + def _load_data_and_files(self): + """ + Parses the request content into self.DATA and self.FILES. + """ + if not _hasattr(self, '_content_type'): + self._load_method_and_content_type() + + if not _hasattr(self, '_data'): + (self._data, self._files) = self._parse() + + def _load_method_and_content_type(self): + """ + Sets the method and content_type, and then check if they've been overridden. + """ + self._content_type = self.META.get('HTTP_CONTENT_TYPE', self.META.get('CONTENT_TYPE', '')) + self._perform_form_overloading() + # if the HTTP method was not overloaded, we take the raw HTTP method + if not _hasattr(self, '_method'): + self._method = self._request.method + + def _load_stream(self): + try: + content_length = int(self.META.get('CONTENT_LENGTH', + self.META.get('HTTP_CONTENT_LENGTH'))) + except (ValueError, TypeError): + content_length = 0 + + if content_length == 0: + self._stream = None + elif hasattr(self._request, 'read'): + self._stream = self._request + else: + self._stream = StringIO(self.raw_post_data) + + def _perform_form_overloading(self): + """ + 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._request.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 = self._request.POST + self._files = self._request.FILES + + # Method overloading - change the method and remove the param from the content. + if self._METHOD_PARAM in self._data: + # NOTE: `pop` on a `QueryDict` returns a list of values. + self._method = self._data.pop(self._METHOD_PARAM)[0].upper() + + # Content overloading - modify the content type, and re-parse. + if (self._CONTENT_PARAM in self._data and + self._CONTENTTYPE_PARAM in self._data): + self._content_type = self._data.pop(self._CONTENTTYPE_PARAM)[0] + self._stream = StringIO(self._data.pop(self._CONTENT_PARAM)[0]) + (self._data, self._files) = self._parse() + + def _parse(self): + """ + Parse the request content. + + May raise a 415 ImmediateResponse (Unsupported Media Type), or a + 400 ImmediateResponse (Bad Request). + """ + if self.stream is None or self.content_type is None: + return (None, None) + + for parser in self.get_parsers(): + if parser.can_handle_request(self.content_type): + return parser.parse(self.stream, self.META, self.upload_handlers) + + self._raise_415_response(self._content_type) + + def _raise_415_response(self, content_type): + """ + Raise a 415 response if we cannot parse the given content type. + """ + from djangorestframework.response import ImmediateResponse + + raise ImmediateResponse( + { + 'error': 'Unsupported media type in request \'%s\'.' + % content_type + }, + status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE) + + def _authenticate(self): + """ + Attempt to authenticate the request using each authentication instance in turn. + Returns a ``User`` object, which may be ``AnonymousUser``. + """ + for authentication in self.get_authentications(): + user = authentication.authenticate(self) + if user: + return user + return AnonymousUser() + + def __getattr__(self, name): + """ + Proxy other attributes to the underlying HttpRequest object. + """ + return getattr(self._request, name) diff --git a/djangorestframework/resources.py b/djangorestframework/resources.py index f170eb45a..e6133d38d 100644 --- a/djangorestframework/resources.py +++ b/djangorestframework/resources.py @@ -1,6 +1,5 @@ from django import forms - -from djangorestframework.response import ErrorResponse +from djangorestframework.response import ImmediateResponse from djangorestframework.serializer import Serializer from djangorestframework.utils import as_tuple @@ -16,14 +15,23 @@ class BaseResource(Serializer): def __init__(self, view=None, depth=None, stack=[], **kwargs): super(BaseResource, self).__init__(depth, stack, **kwargs) - self.view = view - self.request = getattr(view, 'request', None) + # If a view is passed, use that. Otherwise traverse up the stack + # to find a view we can use + if view is not None: + self.view = view + else: + for serializer in stack[::-1]: + if hasattr(serializer, 'view') \ + and getattr(serializer, 'view') != None: + self.view = getattr(serializer, 'view') + break + self.request = getattr(self.view, 'request', None) 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. + Typically raises a :exc:`response.ImmediateResponse` with status code + 400 (Bad Request) on failure. """ return data @@ -75,19 +83,19 @@ class FormResource(Resource): """ Flag to check for unknown fields when validating a form. If set to false and we receive request data that is not expected by the form it raises an - :exc:`response.ErrorResponse` with status code 400. If set to true, only + :exc:`response.ImmediateResponse` with status code 400. If set to true, only expected fields are validated. """ 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. + Raises a :exc:`response.ImmediateResponse` 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 if :attr:`self.allow_unknown_form_fields` is ``False``. - On failure the :exc:`response.ErrorResponse` content is a dict which may contain :obj:`'errors'` and :obj:`'field-errors'` keys. + On failure the :exc:`response.ImmediateResponse` 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', ...]}``. """ @@ -176,7 +184,7 @@ class FormResource(Resource): detail[u'field_errors'] = field_errors # Return HTTP 400 response (BAD REQUEST) - raise ErrorResponse(400, detail) + raise ImmediateResponse(detail, status=400) def get_form_class(self, method=None): """ @@ -272,14 +280,14 @@ class ModelResource(FormResource): 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. + Raises a :exc:`response.ImmediateResponse` 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 :obj:`'errors'` and :obj:`'field-errors'` keys. + On failure the ImmediateResponse 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}. """ diff --git a/djangorestframework/response.py b/djangorestframework/response.py index 13f0477ed..0f12e99c1 100644 --- a/djangorestframework/response.py +++ b/djangorestframework/response.py @@ -1,44 +1,186 @@ """ -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. +The :mod:`response` module provides :class:`Response` and :class:`ImmediateResponse` classes. + +`Response` is a subclass of `HttpResponse`, and can be similarly instantiated and returned +from any view. It is a bit smarter than Django's `HttpResponse`, for it renders automatically +its content to a serial format by using a list of :mod:`renderers`. + +To determine the content type to which it must render, default behaviour is to use standard +HTTP Accept header content negotiation. But `Response` also supports overriding the content type +by specifying an ``_accept=`` parameter in the URL. Also, `Response` will ignore `Accept` headers +from Internet Explorer user agents and use a sensible browser `Accept` header instead. + + +`ImmediateResponse` is an exception that inherits from `Response`. It can be used +to abort the request handling (i.e. ``View.get``, ``View.put``, ...), +and immediately returning a response. """ +from django.template.response import SimpleTemplateResponse from django.core.handlers.wsgi import STATUS_CODE_TEXT -__all__ = ('Response', 'ErrorResponse') - -# TODO: remove raw_content/cleaned_content and just use content? +from djangorestframework.utils.mediatypes import order_by_precedence +from djangorestframework.utils import MSIE_USER_AGENT_REGEX +from djangorestframework import status -class Response(object): +__all__ = ('Response', 'ImmediateResponse') + + +class NotAcceptable(Exception): + pass + + +class Response(SimpleTemplateResponse): """ An HttpResponse that may include content that hasn't yet been serialized. + + Kwargs: + - content(object). The raw content, not yet serialized. This must be simple Python + data that renderers can handle (e.g.: `dict`, `str`, ...) + - renderers(list/tuple). The renderers to use for rendering the response content. """ - def __init__(self, status=200, content=None, headers=None): - self.status = status - self.media_type = None + _ACCEPT_QUERY_PARAM = '_accept' # Allow override of Accept header in URL query params + _IGNORE_IE_ACCEPT_HEADER = True + + def __init__(self, content=None, status=None, headers=None, view=None, request=None, renderers=None): + # First argument taken by `SimpleTemplateResponse.__init__` is template_name, + # which we don't need + super(Response, self).__init__(None, status=status) + + self.raw_content = 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 or {} + self.headers = headers and headers[:] or [] + self.view = view + self.request = request + self.renderers = renderers + + def get_renderers(self): + """ + Instantiates and returns the list of renderers the response will use. + """ + return [renderer(self.view) for renderer in self.renderers] + + @property + def rendered_content(self): + """ + The final rendered content. Accessing this attribute triggers the + complete rendering cycle: selecting suitable renderer, setting + response's actual content type, rendering data. + """ + renderer, media_type = self._determine_renderer() + + # Set the media type of the response + self['Content-Type'] = renderer.media_type + + # Render the response content + if self.has_content_body: + return renderer.render(self.raw_content, media_type) + return renderer.render() + + def render(self): + try: + return super(Response, self).render() + except NotAcceptable: + response = self._get_406_response() + return response.render() @property def status_text(self): """ - Return reason text corresponding to our HTTP response status code. + Returns reason text corresponding to our HTTP response status code. Provided for convenience. """ - return STATUS_CODE_TEXT.get(self.status, '') + return STATUS_CODE_TEXT.get(self.status_code, '') + + def _determine_accept_list(self): + """ + Returns a list of accepted media types. This list is determined from : + + 1. overload with `_ACCEPT_QUERY_PARAM` + 2. `Accept` header of the request + + If those are useless, a default value is returned instead. + """ + request = self.request + + if self._ACCEPT_QUERY_PARAM and request.GET.get(self._ACCEPT_QUERY_PARAM, None): + # Use _accept parameter override + return [request.GET.get(self._ACCEPT_QUERY_PARAM)] + elif (self._IGNORE_IE_ACCEPT_HEADER and + 'HTTP_USER_AGENT' in request.META and + MSIE_USER_AGENT_REGEX.match(request.META['HTTP_USER_AGENT']) and + request.META.get('HTTP_X_REQUESTED_WITH', '') != 'XMLHttpRequest'): + # Ignore MSIE's broken accept behavior and do something sensible instead + return ['text/html', '*/*'] + elif 'HTTP_ACCEPT' in request.META: + # Use standard HTTP Accept negotiation + return [token.strip() for token in request.META['HTTP_ACCEPT'].split(',')] + else: + # No accept header specified + return ['*/*'] + + def _determine_renderer(self): + """ + Determines the appropriate renderer for the output, given the list of + accepted media types, 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 + """ + + renderers = self.get_renderers() + accepts = self._determine_accept_list() + + # Not acceptable response - Ignore accept header. + if self.status_code == 406: + return (renderers[0], renderers[0].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 :) + # Worst case is we're looping over len(accept_list) * len(self.renderers) + for media_type_list in order_by_precedence(accepts): + for renderer in renderers: + for media_type in media_type_list: + if renderer.can_handle_response(media_type): + return renderer, media_type + + # No acceptable renderers were found + raise NotAcceptable + + def _get_406_response(self): + renderer = self.renderers[0] + return Response( + { + 'detail': 'Could not satisfy the client\'s Accept header', + 'available_types': [renderer.media_type + for renderer in self.renderers] + }, + status=status.HTTP_406_NOT_ACCEPTABLE, + view=self.view, request=self.request, renderers=[renderer]) -class ErrorResponse(Exception): +class ImmediateResponse(Response, Exception): """ An exception representing an Response that should be returned immediately. Any content should be serialized as-is, without being filtered. """ + #TODO: this is just a temporary fix, the whole rendering/support for ImmediateResponse, should be remade : see issue #163 - def __init__(self, status, content=None, headers={}): - self.response = Response(status, content=content, headers=headers) + def render(self): + try: + return super(Response, self).render() + except ImmediateResponse: + renderer, media_type = self._determine_renderer() + self.renderers.remove(renderer) + if len(self.renderers) == 0: + raise RuntimeError('Caught an ImmediateResponse while '\ + 'trying to render an ImmediateResponse') + return self.render() + + def __init__(self, *args, **kwargs): + self.response = Response(*args, **kwargs) diff --git a/djangorestframework/serializer.py b/djangorestframework/serializer.py index 027c9daf7..d3be94a35 100644 --- a/djangorestframework/serializer.py +++ b/djangorestframework/serializer.py @@ -100,6 +100,7 @@ class Serializer(object): def __init__(self, depth=None, stack=[], **kwargs): if depth is not None: self.depth = depth + stack.append(self) self.stack = stack def get_fields(self, obj): @@ -173,11 +174,11 @@ class Serializer(object): else: depth = self.depth - 1 + # detect circular references if any([obj is elem for elem in self.stack]): return self.serialize_recursion(obj) else: stack = self.stack[:] - stack.append(obj) return related_serializer(depth=depth, stack=stack).serialize( obj, request=getattr(self, 'request', None)) diff --git a/djangorestframework/templates/djangorestframework/base.html b/djangorestframework/templates/djangorestframework/base.html index 00ecf8c3c..f177f883b 100644 --- a/djangorestframework/templates/djangorestframework/base.html +++ b/djangorestframework/templates/djangorestframework/base.html @@ -21,13 +21,13 @@
{% block userlinks %} - {% if user.is_active %} - Welcome, {{ user }}. - Log out - {% else %} - Anonymous - Log in - {% endif %} + {% if user.is_active %} + Welcome, {{ user }}. + Log out + {% else %} + Anonymous + Log in + {% endif %} {% endblock %}
{% block nav-global %}{% endblock %} @@ -44,7 +44,7 @@
- {% if 'OPTIONS' in view.allowed_methods %} + {% if 'OPTIONS' in allowed_methods %}
{% csrf_token %} @@ -56,12 +56,12 @@

{{ name }}

{{ description }}

-
{{ response.status }} {{ response.status_text }}{% autoescape off %}
-{% for key, val in response.headers.items %}{{ key }}: {{ val|urlize_quoted_links }}
+	    
{{ response.status_code }} {{ response.status_text }}{% autoescape off %}
+{% for key, val in response.items %}{{ key }}: {{ val|urlize_quoted_links }}
 {% endfor %}
 {{ content|urlize_quoted_links }}
{% endautoescape %}
- {% if 'GET' in view.allowed_methods %} + {% if 'GET' in allowed_methods %}

GET {{ name }}

@@ -78,9 +78,9 @@ {% endif %} {# Only display the POST/PUT/DELETE forms if method tunneling via POST forms is enabled and the user has permissions on this view. #} - {% if METHOD_PARAM and response.status != 403 %} + {% if METHOD_PARAM and response.status_code != 403 %} - {% if 'POST' in view.allowed_methods %} + {% if 'POST' in allowed_methods %}

POST {{ name }}

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

PUT {{ name }}

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

DELETE {{ name }}

diff --git a/djangorestframework/tests/__init__.py b/djangorestframework/tests/__init__.py index f664c5c12..641b0277b 100644 --- a/djangorestframework/tests/__init__.py +++ b/djangorestframework/tests/__init__.py @@ -10,4 +10,3 @@ for module in modules: exec("from djangorestframework.tests.%s import __doc__ as module_doc" % module) exec("from djangorestframework.tests.%s import *" % module) __test__[module] = module_doc or "" - diff --git a/djangorestframework/tests/accept.py b/djangorestframework/tests/accept.py index 21aba589b..69bca707b 100644 --- a/djangorestframework/tests/accept.py +++ b/djangorestframework/tests/accept.py @@ -1,7 +1,9 @@ from django.conf.urls.defaults import patterns, url, include from django.test import TestCase + from djangorestframework.compat import RequestFactory from djangorestframework.views import View +from djangorestframework.response import Response # See: http://www.useragentstring.com/ @@ -32,9 +34,10 @@ class UserAgentMungingTest(TestCase): class MockView(View): permissions = () + response_class = Response def get(self, request): - return {'a':1, 'b':2, 'c':3} + return self.response_class({'a':1, 'b':2, 'c':3}) self.req = RequestFactory() self.MockView = MockView @@ -48,18 +51,33 @@ class UserAgentMungingTest(TestCase): MSIE_7_USER_AGENT): req = self.req.get('/', HTTP_ACCEPT='*/*', HTTP_USER_AGENT=user_agent) resp = self.view(req) + resp.render() self.assertEqual(resp['Content-Type'], 'text/html') + def test_dont_munge_msie_with_x_requested_with_header(self): + """Send MSIE user agent strings, and an X-Requested-With header, and + ensure that we get a JSON response if we set a */* Accept header.""" + for user_agent in (MSIE_9_USER_AGENT, + MSIE_8_USER_AGENT, + MSIE_7_USER_AGENT): + req = self.req.get('/', HTTP_ACCEPT='*/*', HTTP_USER_AGENT=user_agent, HTTP_X_REQUESTED_WITH='XMLHttpRequest') + resp = self.view(req) + resp.render() + self.assertEqual(resp['Content-Type'], 'application/json') + def test_dont_rewrite_msie_accept_header(self): """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(_IGNORE_IE_ACCEPT_HEADER=False) + class IgnoreIEAcceptResponse(Response): + _IGNORE_IE_ACCEPT_HEADER=False + view = self.MockView.as_view(response_class=IgnoreIEAcceptResponse) for user_agent in (MSIE_9_USER_AGENT, MSIE_8_USER_AGENT, MSIE_7_USER_AGENT): req = self.req.get('/', HTTP_ACCEPT='*/*', HTTP_USER_AGENT=user_agent) resp = view(req) + resp.render() self.assertEqual(resp['Content-Type'], 'application/json') def test_dont_munge_nice_browsers_accept_header(self): @@ -72,5 +90,6 @@ class UserAgentMungingTest(TestCase): OPERA_11_0_OPERA_USER_AGENT): req = self.req.get('/', HTTP_ACCEPT='*/*', HTTP_USER_AGENT=user_agent) resp = self.view(req) + resp.render() self.assertEqual(resp['Content-Type'], 'application/json') diff --git a/djangorestframework/tests/authentication.py b/djangorestframework/tests/authentication.py index 303bf96be..5debc79a6 100644 --- a/djangorestframework/tests/authentication.py +++ b/djangorestframework/tests/authentication.py @@ -3,6 +3,7 @@ from django.contrib.auth.models import User from django.test import Client, TestCase from django.utils import simplejson as json +from django.http import HttpResponse from djangorestframework.views import View from djangorestframework import permissions @@ -11,13 +12,13 @@ import base64 class MockView(View): - permissions = (permissions.IsAuthenticated,) + permissions_classes = (permissions.IsAuthenticated,) def post(self, request): - return {'a': 1, 'b': 2, 'c': 3} + return HttpResponse({'a': 1, 'b': 2, 'c': 3}) def put(self, request): - return {'a': 1, 'b': 2, 'c': 3} + return HttpResponse({'a': 1, 'b': 2, 'c': 3}) urlpatterns = patterns('', (r'^$', MockView.as_view()), diff --git a/djangorestframework/tests/content.py b/djangorestframework/tests/content.py deleted file mode 100644 index 6bae8feb3..000000000 --- a/djangorestframework/tests/content.py +++ /dev/null @@ -1,233 +0,0 @@ -""" -Tests for content parsing, and form-overloaded content parsing. -""" -from django.conf.urls.defaults import patterns -from django.contrib.auth.models import User -from django.test import TestCase, Client -from djangorestframework import status -from djangorestframework.authentication import UserLoggedInAuthentication -from djangorestframework.compat import RequestFactory, unittest -from djangorestframework.mixins import RequestMixin -from djangorestframework.parsers import FormParser, MultiPartParser, \ - PlainTextParser, JSONParser -from djangorestframework.response import Response -from djangorestframework.views import View - -class MockView(View): - authentication = (UserLoggedInAuthentication,) - def post(self, request): - if request.POST.get('example') is not None: - return Response(status.HTTP_200_OK) - - return Response(status.INTERNAL_SERVER_ERROR) - -urlpatterns = patterns('', - (r'^$', MockView.as_view()), -) - -class TestContentParsing(TestCase): - def setUp(self): - self.req = RequestFactory() - - def ensure_determines_no_content_GET(self, view): - """Ensure view.DATA returns None for GET request with no content.""" - view.request = self.req.get('/') - self.assertEqual(view.DATA, None) - - def ensure_determines_no_content_HEAD(self, view): - """Ensure view.DATA returns None for HEAD request.""" - view.request = self.req.head('/') - self.assertEqual(view.DATA, None) - - def ensure_determines_form_content_POST(self, view): - """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.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.""" - content = 'qwerty' - content_type = 'text/plain' - view.parsers = (PlainTextParser,) - view.request = self.req.post('/', content, content_type=content_type) - 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.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.""" - content = 'qwerty' - content_type = 'text/plain' - view.parsers = (PlainTextParser,) - view.request = self.req.post('/', content, content_type=content_type) - self.assertEqual(view.DATA, content) - - def test_standard_behaviour_determines_no_content_GET(self): - """Ensure view.DATA returns None for GET request with no content.""" - self.ensure_determines_no_content_GET(RequestMixin()) - - def test_standard_behaviour_determines_no_content_HEAD(self): - """Ensure view.DATA returns None for HEAD request.""" - self.ensure_determines_no_content_HEAD(RequestMixin()) - - def test_standard_behaviour_determines_form_content_POST(self): - """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.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.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.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.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} - view.request = self.req.post('/', form_data) - view.parsers = (PlainTextParser,) - self.assertEqual(view.DATA, content) - - def test_accessing_post_after_data_form(self): - """Ensures request.POST can be accessed after request.DATA in form request""" - form_data = {'qwerty': 'uiop'} - view = RequestMixin() - view.parsers = (FormParser, MultiPartParser) - view.request = self.req.post('/', data=form_data) - - self.assertEqual(view.DATA.items(), form_data.items()) - self.assertEqual(view.request.POST.items(), form_data.items()) - - @unittest.skip('This test was disabled some time ago for some reason') - def test_accessing_post_after_data_for_json(self): - """Ensures request.POST can be accessed after request.DATA in json request""" - from django.utils import simplejson as json - - data = {'qwerty': 'uiop'} - content = json.dumps(data) - content_type = 'application/json' - - view = RequestMixin() - view.parsers = (JSONParser,) - - view.request = self.req.post('/', content, content_type=content_type) - - self.assertEqual(view.DATA.items(), data.items()) - self.assertEqual(view.request.POST.items(), []) - - def test_accessing_post_after_data_for_overloaded_json(self): - """Ensures request.POST can be accessed after request.DATA in overloaded json request""" - from django.utils import simplejson as json - - data = {'qwerty': 'uiop'} - content = json.dumps(data) - content_type = 'application/json' - - view = RequestMixin() - view.parsers = (JSONParser,) - - form_data = {view._CONTENT_PARAM: content, - view._CONTENTTYPE_PARAM: content_type} - - view.request = self.req.post('/', data=form_data) - - self.assertEqual(view.DATA.items(), data.items()) - self.assertEqual(view.request.POST.items(), form_data.items()) - - def test_accessing_data_after_post_form(self): - """Ensures request.DATA can be accessed after request.POST in form request""" - form_data = {'qwerty': 'uiop'} - view = RequestMixin() - view.parsers = (FormParser, MultiPartParser) - view.request = self.req.post('/', data=form_data) - - self.assertEqual(view.request.POST.items(), form_data.items()) - self.assertEqual(view.DATA.items(), form_data.items()) - - def test_accessing_data_after_post_for_json(self): - """Ensures request.DATA can be accessed after request.POST in json request""" - from django.utils import simplejson as json - - data = {'qwerty': 'uiop'} - content = json.dumps(data) - content_type = 'application/json' - - view = RequestMixin() - view.parsers = (JSONParser,) - - view.request = self.req.post('/', content, content_type=content_type) - - post_items = view.request.POST.items() - - self.assertEqual(len(post_items), 1) - self.assertEqual(len(post_items[0]), 2) - self.assertEqual(post_items[0][0], content) - self.assertEqual(view.DATA.items(), data.items()) - - def test_accessing_data_after_post_for_overloaded_json(self): - """Ensures request.DATA can be accessed after request.POST in overloaded json request""" - from django.utils import simplejson as json - - data = {'qwerty': 'uiop'} - content = json.dumps(data) - content_type = 'application/json' - - view = RequestMixin() - view.parsers = (JSONParser,) - - form_data = {view._CONTENT_PARAM: content, - view._CONTENTTYPE_PARAM: content_type} - - view.request = self.req.post('/', data=form_data) - - self.assertEqual(view.request.POST.items(), form_data.items()) - self.assertEqual(view.DATA.items(), data.items()) - -class TestContentParsingWithAuthentication(TestCase): - urls = 'djangorestframework.tests.content' - - def setUp(self): - self.csrf_client = Client(enforce_csrf_checks=True) - self.username = 'john' - self.email = 'lennon@thebeatles.com' - self.password = 'password' - self.user = User.objects.create_user(self.username, self.email, self.password) - self.req = RequestFactory() - - def test_user_logged_in_authentication_has_post_when_not_logged_in(self): - """Ensures request.POST exists after UserLoggedInAuthentication when user doesn't log in""" - content = {'example': 'example'} - - response = self.client.post('/', content) - self.assertEqual(status.HTTP_200_OK, response.status_code, "POST data is malformed") - - response = self.csrf_client.post('/', content) - self.assertEqual(status.HTTP_200_OK, response.status_code, "POST data is malformed") - - # def test_user_logged_in_authentication_has_post_when_logged_in(self): - # """Ensures request.POST exists after UserLoggedInAuthentication when user does log in""" - # self.client.login(username='john', password='password') - # self.csrf_client.login(username='john', password='password') - # content = {'example': 'example'} - - # response = self.client.post('/', content) - # self.assertEqual(status.OK, response.status_code, "POST data is malformed") - - # response = self.csrf_client.post('/', content) - # self.assertEqual(status.OK, response.status_code, "POST data is malformed") diff --git a/djangorestframework/tests/files.py b/djangorestframework/tests/files.py index d3b1cc561..bbdff70bd 100644 --- a/djangorestframework/tests/files.py +++ b/djangorestframework/tests/files.py @@ -1,8 +1,11 @@ from django.test import TestCase from django import forms + from djangorestframework.compat import RequestFactory from djangorestframework.views import View from djangorestframework.resources import FormResource +from djangorestframework.response import Response + import StringIO class UploadFilesTests(TestCase): @@ -20,13 +23,13 @@ class UploadFilesTests(TestCase): form = FileForm def post(self, request, *args, **kwargs): - return {'FILE_NAME': self.CONTENT['file'].name, - 'FILE_CONTENT': self.CONTENT['file'].read()} + return Response({'FILE_NAME': self.CONTENT['file'].name, + 'FILE_CONTENT': self.CONTENT['file'].read()}) file = StringIO.StringIO('stuff') file.name = 'stuff.txt' request = self.factory.post('/', {'file': file}) view = MockView.as_view() response = view(request) - self.assertEquals(response.content, '{"FILE_CONTENT": "stuff", "FILE_NAME": "stuff.txt"}') + self.assertEquals(response.raw_content, {"FILE_CONTENT": "stuff", "FILE_NAME": "stuff.txt"}) diff --git a/djangorestframework/tests/methods.py b/djangorestframework/tests/methods.py index 4b90a21f1..e69de29bb 100644 --- a/djangorestframework/tests/methods.py +++ b/djangorestframework/tests/methods.py @@ -1,32 +0,0 @@ -from django.test import TestCase -from djangorestframework.compat import RequestFactory -from djangorestframework.mixins import RequestMixin - - -class TestMethodOverloading(TestCase): - def setUp(self): - self.req = RequestFactory() - - 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'}) - self.assertEqual(view.method, 'DELETE') - - def test_HEAD_is_a_valid_method(self): - """HEAD requests identified""" - view = RequestMixin() - view.request = self.req.head('/') - self.assertEqual(view.method, 'HEAD') diff --git a/djangorestframework/tests/mixins.py b/djangorestframework/tests/mixins.py index 8268fdca7..25c57bd6e 100644 --- a/djangorestframework/tests/mixins.py +++ b/djangorestframework/tests/mixins.py @@ -6,7 +6,7 @@ from djangorestframework.compat import RequestFactory from django.contrib.auth.models import Group, User from djangorestframework.mixins import CreateModelMixin, PaginatorMixin, ReadModelMixin from djangorestframework.resources import ModelResource -from djangorestframework.response import Response, ErrorResponse +from djangorestframework.response import Response, ImmediateResponse from djangorestframework.tests.models import CustomUser from djangorestframework.tests.testcases import TestModelsTestCase from djangorestframework.views import View @@ -31,7 +31,7 @@ class TestModelRead(TestModelsTestCase): mixin.resource = GroupResource response = mixin.get(request, id=group.id) - self.assertEquals(group.name, response.name) + self.assertEquals(group.name, response.raw_content.name) def test_read_404(self): class GroupResource(ModelResource): @@ -41,7 +41,7 @@ class TestModelRead(TestModelsTestCase): mixin = ReadModelMixin() mixin.resource = GroupResource - self.assertRaises(ErrorResponse, mixin.get, request, id=12345) + self.assertRaises(ImmediateResponse, mixin.get, request, id=12345) class TestModelCreation(TestModelsTestCase): @@ -65,7 +65,7 @@ class TestModelCreation(TestModelsTestCase): response = mixin.post(request) self.assertEquals(1, Group.objects.count()) - self.assertEquals('foo', response.cleaned_content.name) + self.assertEquals('foo', response.raw_content.name) def test_creation_with_m2m_relation(self): class UserResource(ModelResource): @@ -91,8 +91,8 @@ class TestModelCreation(TestModelsTestCase): response = mixin.post(request) self.assertEquals(1, User.objects.count()) - self.assertEquals(1, response.cleaned_content.groups.count()) - self.assertEquals('foo', response.cleaned_content.groups.all()[0].name) + self.assertEquals(1, response.raw_content.groups.count()) + self.assertEquals('foo', response.raw_content.groups.all()[0].name) def test_creation_with_m2m_relation_through(self): """ @@ -114,7 +114,7 @@ class TestModelCreation(TestModelsTestCase): response = mixin.post(request) self.assertEquals(1, CustomUser.objects.count()) - self.assertEquals(0, response.cleaned_content.groups.count()) + self.assertEquals(0, response.raw_content.groups.count()) group = Group(name='foo1') group.save() @@ -129,8 +129,8 @@ class TestModelCreation(TestModelsTestCase): response = mixin.post(request) self.assertEquals(2, CustomUser.objects.count()) - self.assertEquals(1, response.cleaned_content.groups.count()) - self.assertEquals('foo1', response.cleaned_content.groups.all()[0].name) + self.assertEquals(1, response.raw_content.groups.count()) + self.assertEquals('foo1', response.raw_content.groups.all()[0].name) group2 = Group(name='foo2') group2.save() @@ -145,19 +145,19 @@ class TestModelCreation(TestModelsTestCase): response = mixin.post(request) self.assertEquals(3, CustomUser.objects.count()) - self.assertEquals(2, response.cleaned_content.groups.count()) - self.assertEquals('foo1', response.cleaned_content.groups.all()[0].name) - self.assertEquals('foo2', response.cleaned_content.groups.all()[1].name) + self.assertEquals(2, response.raw_content.groups.count()) + self.assertEquals('foo1', response.raw_content.groups.all()[0].name) + self.assertEquals('foo2', response.raw_content.groups.all()[1].name) class MockPaginatorView(PaginatorMixin, View): total = 60 def get(self, request): - return range(0, self.total) + return Response(range(0, self.total)) def post(self, request): - return Response(status.HTTP_201_CREATED, {'status': 'OK'}) + return Response({'status': 'OK'}, status=status.HTTP_201_CREATED) class TestPagination(TestCase): @@ -168,8 +168,7 @@ class TestPagination(TestCase): """ Tests if pagination works without overwriting the limit """ request = self.req.get('/paginator') response = MockPaginatorView.as_view()(request) - - content = json.loads(response.content) + content = response.raw_content self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(MockPaginatorView.total, content['total']) @@ -183,8 +182,7 @@ class TestPagination(TestCase): request = self.req.get('/paginator') response = MockPaginatorView.as_view(limit=limit)(request) - - content = json.loads(response.content) + content = response.raw_content self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(content['per_page'], limit) @@ -200,8 +198,7 @@ class TestPagination(TestCase): request = self.req.get('/paginator/?limit=%d' % limit) response = MockPaginatorView.as_view()(request) - - content = json.loads(response.content) + content = response.raw_content self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(MockPaginatorView.total, content['total']) @@ -217,8 +214,7 @@ class TestPagination(TestCase): request = self.req.get('/paginator/?limit=%d' % limit) response = MockPaginatorView.as_view()(request) - - content = json.loads(response.content) + content = response.raw_content self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(MockPaginatorView.total, content['total']) @@ -230,8 +226,7 @@ class TestPagination(TestCase): """ Pagination should only work for GET requests """ request = self.req.post('/paginator', data={'content': 'spam'}) response = MockPaginatorView.as_view()(request) - - content = json.loads(response.content) + content = response.raw_content self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(None, content.get('per_page')) @@ -248,12 +243,12 @@ class TestPagination(TestCase): """ Tests that the page range is handle correctly """ request = self.req.get('/paginator/?page=0') response = MockPaginatorView.as_view()(request) - content = json.loads(response.content) + content = response.raw_content self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) request = self.req.get('/paginator/') response = MockPaginatorView.as_view()(request) - content = json.loads(response.content) + content = response.raw_content self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(range(0, MockPaginatorView.limit), content['results']) @@ -261,13 +256,13 @@ class TestPagination(TestCase): request = self.req.get('/paginator/?page=%d' % num_pages) response = MockPaginatorView.as_view()(request) - content = json.loads(response.content) + content = response.raw_content self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(range(MockPaginatorView.limit*(num_pages-1), MockPaginatorView.total), content['results']) request = self.req.get('/paginator/?page=%d' % (num_pages + 1,)) response = MockPaginatorView.as_view()(request) - content = json.loads(response.content) + content = response.raw_content self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) def test_existing_query_parameters_are_preserved(self): @@ -275,7 +270,7 @@ class TestPagination(TestCase): generating next/previous page links """ request = self.req.get('/paginator/?foo=bar&another=something') response = MockPaginatorView.as_view()(request) - content = json.loads(response.content) + content = response.raw_content self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue('foo=bar' in content['next']) self.assertTrue('another=something' in content['next']) @@ -286,6 +281,6 @@ class TestPagination(TestCase): paginated URLs. So page 1 should contain ?page=2, not ?page=1&page=2 """ request = self.req.get('/paginator/?page=1') response = MockPaginatorView.as_view()(request) - content = json.loads(response.content) + content = response.raw_content self.assertTrue('page=2' in content['next']) self.assertFalse('page=1' in content['next']) diff --git a/djangorestframework/tests/parsers.py b/djangorestframework/tests/parsers.py index 1ecc17602..c733d9d09 100644 --- a/djangorestframework/tests/parsers.py +++ b/djangorestframework/tests/parsers.py @@ -132,30 +132,32 @@ # self.assertEqual(files['file1'].read(), 'blablabla') from StringIO import StringIO -from cgi import parse_qs from django import forms from django.test import TestCase from djangorestframework.parsers import FormParser from djangorestframework.parsers import XMLParser import datetime + class Form(forms.Form): field1 = forms.CharField(max_length=3) field2 = forms.CharField() + class TestFormParser(TestCase): def setUp(self): self.string = "field1=abc&field2=defghijk" def test_parse(self): """ Make sure the `QueryDict` works OK """ - parser = FormParser(None) + parser = FormParser() stream = StringIO(self.string) - (data, files) = parser.parse(stream) + (data, files) = parser.parse(stream, {}, []) self.assertEqual(Form(data).is_valid(), True) + class TestXMLParser(TestCase): def setUp(self): self._input = StringIO( @@ -163,13 +165,13 @@ class TestXMLParser(TestCase): '' '121.0' 'dasd' - '' + '' '2011-12-25 12:45:00' '' - ) - self._data = { + ) + self._data = { 'field_a': 121, - 'field_b': 'dasd', + 'field_b': 'dasd', 'field_c': None, 'field_d': datetime.datetime(2011, 12, 25, 12, 45, 00) } @@ -183,28 +185,28 @@ class TestXMLParser(TestCase): '' 'name' '' - ) + ) self._complex_data = { - "creation_date": datetime.datetime(2011, 12, 25, 12, 45, 00), - "name": "name", + "creation_date": datetime.datetime(2011, 12, 25, 12, 45, 00), + "name": "name", "sub_data_list": [ { - "sub_id": 1, + "sub_id": 1, "sub_name": "first" - }, + }, { - "sub_id": 2, + "sub_id": 2, "sub_name": "second" } ] - } + } def test_parse(self): - parser = XMLParser(None) - (data, files) = parser.parse(self._input) + parser = XMLParser() + (data, files) = parser.parse(self._input, {}, []) self.assertEqual(data, self._data) def test_complex_data_parse(self): - parser = XMLParser(None) - (data, files) = parser.parse(self._complex_data_input) + parser = XMLParser() + (data, files) = parser.parse(self._complex_data_input, {}, []) self.assertEqual(data, self._complex_data) diff --git a/djangorestframework/tests/renderers.py b/djangorestframework/tests/renderers.py index 3ed5ab28f..fce4af649 100644 --- a/djangorestframework/tests/renderers.py +++ b/djangorestframework/tests/renderers.py @@ -4,18 +4,19 @@ from django.conf.urls.defaults import patterns, url, include from django.test import TestCase from djangorestframework import status -from djangorestframework.views import View from djangorestframework.compat import View as DjangoView +from djangorestframework.response import Response +from djangorestframework.mixins import ResponseMixin +from djangorestframework.views import View from djangorestframework.renderers import BaseRenderer, JSONRenderer, YAMLRenderer, \ XMLRenderer, JSONPRenderer, DocumentingHTMLRenderer -from djangorestframework.parsers import JSONParser, YAMLParser, XMLParser -from djangorestframework.mixins import ResponseMixin -from djangorestframework.response import Response +from djangorestframework.parsers import YAMLParser, XMLParser from StringIO import StringIO import datetime from decimal import Decimal + DUMMYSTATUS = status.HTTP_200_OK DUMMYCONTENT = 'dummycontent' @@ -57,14 +58,14 @@ class HTMLView(View): renderers = (DocumentingHTMLRenderer, ) def get(self, request, **kwargs): - return 'text' + return 'text' class HTMLView1(View): renderers = (DocumentingHTMLRenderer, JSONRenderer) def get(self, request, **kwargs): - return 'text' + return 'text' urlpatterns = patterns('', url(r'^.*\.(?P.+)$', MockView.as_view(renderers=[RendererA, RendererB])), @@ -167,7 +168,7 @@ class RendererIntegrationTests(TestCase): self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT)) self.assertEquals(resp.status_code, DUMMYSTATUS) - def test_bla(self): + def test_bla(self): # What the f***? resp = self.client.get('/?format=formatb', HTTP_ACCEPT='text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8') self.assertEquals(resp['Content-Type'], RendererB.media_type) @@ -185,6 +186,7 @@ def strip_trailing_whitespace(content): """ return re.sub(' +\n', '\n', content) + class JSONRendererTests(TestCase): """ Tests specific to the JSON Renderer @@ -209,19 +211,17 @@ class JSONRendererTests(TestCase): content = renderer.render(obj, 'application/json; indent=2') self.assertEquals(strip_trailing_whitespace(content), _indented_repr) - def test_render_and_parse(self): - """ - Test rendering and then parsing returns the original object. - IE obj -> render -> parse -> obj. - """ - obj = {'foo': ['bar', 'baz']} - renderer = JSONRenderer(None) - parser = JSONParser(None) +class MockGETView(View): - content = renderer.render(obj, 'application/json') - (data, files) = parser.parse(StringIO(content)) - self.assertEquals(obj, data) + def get(self, request, *args, **kwargs): + return Response({'foo': ['bar', 'baz']}) + + +urlpatterns = patterns('', + url(r'^jsonp/jsonrenderer$', MockGETView.as_view(renderers=[JSONRenderer, JSONPRenderer])), + url(r'^jsonp/nojsonrenderer$', MockGETView.as_view(renderers=[JSONPRenderer])), +) class JSONPRendererTests(TestCase): @@ -295,22 +295,21 @@ if YAMLRenderer: self.assertEquals(obj, data) - class XMLRendererTestCase(TestCase): """ Tests specific to the XML Renderer """ _complex_data = { - "creation_date": datetime.datetime(2011, 12, 25, 12, 45, 00), - "name": "name", + "creation_date": datetime.datetime(2011, 12, 25, 12, 45, 00), + "name": "name", "sub_data_list": [ { - "sub_id": 1, + "sub_id": 1, "sub_name": "first" - }, + }, { - "sub_id": 2, + "sub_id": 2, "sub_name": "second" } ] @@ -365,12 +364,12 @@ class XMLRendererTestCase(TestCase): renderer = XMLRenderer(None) content = renderer.render({'field': None}, 'application/xml') self.assertXMLContains(content, '') - + def test_render_complex_data(self): """ Test XML rendering. """ - renderer = XMLRenderer(None) + renderer = XMLRenderer(None) content = renderer.render(self._complex_data, 'application/xml') self.assertXMLContains(content, 'first') self.assertXMLContains(content, 'second') @@ -379,11 +378,11 @@ class XMLRendererTestCase(TestCase): """ Test XML rendering. """ - renderer = XMLRenderer(None) + renderer = XMLRenderer(None) content = StringIO(renderer.render(self._complex_data, 'application/xml')) - - parser = XMLParser(None) - complex_data_out, dummy = parser.parse(content) + + parser = XMLParser() + complex_data_out, dummy = parser.parse(content, {}, []) error_msg = "complex data differs!IN:\n %s \n\n OUT:\n %s" % (repr(self._complex_data), repr(complex_data_out)) self.assertEqual(self._complex_data, complex_data_out, error_msg) @@ -391,22 +390,3 @@ class XMLRendererTestCase(TestCase): self.assertTrue(xml.startswith('\n')) self.assertTrue(xml.endswith('')) self.assertTrue(string in xml, '%r not in %r' % (string, xml)) - - -class Issue122Tests(TestCase): - """ - Tests that covers #122. - """ - urls = 'djangorestframework.tests.renderers' - - def test_only_html_renderer(self): - """ - Test if no infinite recursion occurs. - """ - resp = self.client.get('/html') - - def test_html_renderer_is_first(self): - """ - Test if no infinite recursion occurs. - """ - resp = self.client.get('/html1') diff --git a/djangorestframework/tests/request.py b/djangorestframework/tests/request.py new file mode 100644 index 000000000..85b2f4186 --- /dev/null +++ b/djangorestframework/tests/request.py @@ -0,0 +1,259 @@ +""" +Tests for content parsing, and form-overloaded content parsing. +""" +from django.conf.urls.defaults import patterns +from django.contrib.auth.models import User +from django.test import TestCase, Client +from django.utils import simplejson as json + +from djangorestframework import status +from djangorestframework.authentication import UserLoggedInAuthentication +from djangorestframework.utils import RequestFactory +from djangorestframework.parsers import ( + FormParser, + MultiPartParser, + PlainTextParser, + JSONParser +) +from djangorestframework.request import Request +from djangorestframework.response import Response +from djangorestframework.views import View + + +factory = RequestFactory() + + +class TestMethodOverloading(TestCase): + def test_GET_method(self): + """ + GET requests identified. + """ + request = factory.get('/') + self.assertEqual(request.method, 'GET') + + def test_POST_method(self): + """ + POST requests identified. + """ + request = factory.post('/') + self.assertEqual(request.method, 'POST') + + def test_HEAD_method(self): + """ + HEAD requests identified. + """ + request = factory.head('/') + self.assertEqual(request.method, 'HEAD') + + def test_overloaded_method(self): + """ + POST requests can be overloaded to another method by setting a + reserved form field + """ + request = factory.post('/', {Request._METHOD_PARAM: 'DELETE'}) + self.assertEqual(request.method, 'DELETE') + + +class TestContentParsing(TestCase): + def test_standard_behaviour_determines_no_content_GET(self): + """ + Ensure request.DATA returns None for GET request with no content. + """ + request = factory.get('/') + self.assertEqual(request.DATA, None) + + def test_standard_behaviour_determines_no_content_HEAD(self): + """ + Ensure request.DATA returns None for HEAD request. + """ + request = factory.head('/') + self.assertEqual(request.DATA, None) + + def test_standard_behaviour_determines_form_content_POST(self): + """ + Ensure request.DATA returns content for POST request with form content. + """ + data = {'qwerty': 'uiop'} + parsers = (FormParser, MultiPartParser) + request = factory.post('/', data, parser=parsers) + self.assertEqual(request.DATA.items(), data.items()) + + def test_standard_behaviour_determines_non_form_content_POST(self): + """ + Ensure request.DATA returns content for POST request with + non-form content. + """ + content = 'qwerty' + content_type = 'text/plain' + parsers = (PlainTextParser,) + request = factory.post('/', content, content_type=content_type, + parsers=parsers) + self.assertEqual(request.DATA, content) + + def test_standard_behaviour_determines_form_content_PUT(self): + """ + Ensure request.DATA returns content for PUT request with form content. + """ + data = {'qwerty': 'uiop'} + parsers = (FormParser, MultiPartParser) + request = factory.put('/', data, parsers=parsers) + self.assertEqual(request.DATA.items(), data.items()) + + def test_standard_behaviour_determines_non_form_content_PUT(self): + """ + Ensure request.DATA returns content for PUT request with + non-form content. + """ + content = 'qwerty' + content_type = 'text/plain' + parsers = (PlainTextParser, ) + request = factory.put('/', content, content_type=content_type, + parsers=parsers) + self.assertEqual(request.DATA, content) + + def test_overloaded_behaviour_allows_content_tunnelling(self): + """ + Ensure request.DATA returns content for overloaded POST request. + """ + content = 'qwerty' + content_type = 'text/plain' + data = { + Request._CONTENT_PARAM: content, + Request._CONTENTTYPE_PARAM: content_type + } + parsers = (PlainTextParser, ) + request = factory.post('/', data, parsers=parsers) + self.assertEqual(request.DATA, content) + + # def test_accessing_post_after_data_form(self): + # """ + # Ensures request.POST can be accessed after request.DATA in + # form request. + # """ + # data = {'qwerty': 'uiop'} + # request = factory.post('/', data=data) + # self.assertEqual(request.DATA.items(), data.items()) + # self.assertEqual(request.POST.items(), data.items()) + + # def test_accessing_post_after_data_for_json(self): + # """ + # Ensures request.POST can be accessed after request.DATA in + # json request. + # """ + # data = {'qwerty': 'uiop'} + # content = json.dumps(data) + # content_type = 'application/json' + # parsers = (JSONParser, ) + + # request = factory.post('/', content, content_type=content_type, + # parsers=parsers) + # self.assertEqual(request.DATA.items(), data.items()) + # self.assertEqual(request.POST.items(), []) + + # def test_accessing_post_after_data_for_overloaded_json(self): + # """ + # Ensures request.POST can be accessed after request.DATA in overloaded + # json request. + # """ + # data = {'qwerty': 'uiop'} + # content = json.dumps(data) + # content_type = 'application/json' + # parsers = (JSONParser, ) + # form_data = {Request._CONTENT_PARAM: content, + # Request._CONTENTTYPE_PARAM: content_type} + + # request = factory.post('/', form_data, parsers=parsers) + # self.assertEqual(request.DATA.items(), data.items()) + # self.assertEqual(request.POST.items(), form_data.items()) + + # def test_accessing_data_after_post_form(self): + # """ + # Ensures request.DATA can be accessed after request.POST in + # form request. + # """ + # data = {'qwerty': 'uiop'} + # parsers = (FormParser, MultiPartParser) + # request = factory.post('/', data, parsers=parsers) + + # self.assertEqual(request.POST.items(), data.items()) + # self.assertEqual(request.DATA.items(), data.items()) + + # def test_accessing_data_after_post_for_json(self): + # """ + # Ensures request.DATA can be accessed after request.POST in + # json request. + # """ + # data = {'qwerty': 'uiop'} + # content = json.dumps(data) + # content_type = 'application/json' + # parsers = (JSONParser, ) + # request = factory.post('/', content, content_type=content_type, + # parsers=parsers) + # self.assertEqual(request.POST.items(), []) + # self.assertEqual(request.DATA.items(), data.items()) + + # def test_accessing_data_after_post_for_overloaded_json(self): + # """ + # Ensures request.DATA can be accessed after request.POST in overloaded + # json request + # """ + # data = {'qwerty': 'uiop'} + # content = json.dumps(data) + # content_type = 'application/json' + # parsers = (JSONParser, ) + # form_data = {Request._CONTENT_PARAM: content, + # Request._CONTENTTYPE_PARAM: content_type} + + # request = factory.post('/', form_data, parsers=parsers) + # self.assertEqual(request.POST.items(), form_data.items()) + # self.assertEqual(request.DATA.items(), data.items()) + + +class MockView(View): + authentication = (UserLoggedInAuthentication,) + + def post(self, request): + if request.POST.get('example') is not None: + return Response(status=status.HTTP_200_OK) + + return Response(status=status.INTERNAL_SERVER_ERROR) + +urlpatterns = patterns('', + (r'^$', MockView.as_view()), +) + + +class TestContentParsingWithAuthentication(TestCase): + urls = 'djangorestframework.tests.request' + + def setUp(self): + self.csrf_client = Client(enforce_csrf_checks=True) + self.username = 'john' + self.email = 'lennon@thebeatles.com' + self.password = 'password' + self.user = User.objects.create_user(self.username, self.email, self.password) + + def test_user_logged_in_authentication_has_POST_when_not_logged_in(self): + """ + Ensures request.POST exists after UserLoggedInAuthentication when user + doesn't log in. + """ + content = {'example': 'example'} + + response = self.client.post('/', content) + self.assertEqual(status.HTTP_200_OK, response.status_code) + + response = self.csrf_client.post('/', content) + self.assertEqual(status.HTTP_200_OK, response.status_code) + + # def test_user_logged_in_authentication_has_post_when_logged_in(self): + # """Ensures request.POST exists after UserLoggedInAuthentication when user does log in""" + # self.client.login(username='john', password='password') + # self.csrf_client.login(username='john', password='password') + # content = {'example': 'example'} + + # response = self.client.post('/', content) + # self.assertEqual(status.OK, response.status_code, "POST data is malformed") + + # response = self.csrf_client.post('/', content) + # self.assertEqual(status.OK, response.status_code, "POST data is malformed") diff --git a/djangorestframework/tests/response.py b/djangorestframework/tests/response.py index d973deb45..07d0f4fbf 100644 --- a/djangorestframework/tests/response.py +++ b/djangorestframework/tests/response.py @@ -1,19 +1,309 @@ -# Right now we expect this test to fail - I'm just going to leave it commented out. -# Looking forward to actually being able to raise ExpectedFailure sometime! -# -#from django.test import TestCase -#from djangorestframework.response import Response -# -# -#class TestResponse(TestCase): -# -# # Interface tests -# -# # This is mainly to remind myself that the Response interface needs to change slightly -# def test_response_interface(self): -# """Ensure the Response interface is as expected.""" -# response = Response() -# getattr(response, 'status') -# getattr(response, 'content') -# getattr(response, 'headers') +import json +import unittest +from django.conf.urls.defaults import patterns, url, include +from django.test import TestCase + +from djangorestframework.response import Response, NotAcceptable, ImmediateResponse +from djangorestframework.views import View +from djangorestframework.compat import RequestFactory +from djangorestframework import status +from djangorestframework.renderers import ( + BaseRenderer, + JSONRenderer, + DocumentingHTMLRenderer, + DEFAULT_RENDERERS +) + + +class MockPickleRenderer(BaseRenderer): + media_type = 'application/pickle' + + +class MockJsonRenderer(BaseRenderer): + media_type = 'application/json' + + +class TestResponseDetermineRenderer(TestCase): + + def get_response(self, url='', accept_list=[], renderers=[]): + kwargs = {} + if accept_list is not None: + kwargs['HTTP_ACCEPT'] = ','.join(accept_list) + request = RequestFactory().get(url, **kwargs) + return Response(request=request, renderers=renderers) + + def test_determine_accept_list_accept_header(self): + """ + Test that determine_accept_list takes the Accept header. + """ + accept_list = ['application/pickle', 'application/json'] + response = self.get_response(accept_list=accept_list) + self.assertEqual(response._determine_accept_list(), accept_list) + + def test_determine_accept_list_default(self): + """ + Test that determine_accept_list takes the default renderer if Accept is not specified. + """ + response = self.get_response(accept_list=None) + self.assertEqual(response._determine_accept_list(), ['*/*']) + + def test_determine_accept_list_overriden_header(self): + """ + Test Accept header overriding. + """ + accept_list = ['application/pickle', 'application/json'] + response = self.get_response(url='?_accept=application/x-www-form-urlencoded', + accept_list=accept_list) + self.assertEqual(response._determine_accept_list(), ['application/x-www-form-urlencoded']) + + def test_determine_renderer(self): + """ + Test that right renderer is chosen, in the order of Accept list. + """ + accept_list = ['application/pickle', 'application/json'] + renderers = (MockPickleRenderer, MockJsonRenderer) + response = self.get_response(accept_list=accept_list, renderers=renderers) + renderer, media_type = response._determine_renderer() + self.assertEqual(media_type, 'application/pickle') + self.assertTrue(isinstance(renderer, MockPickleRenderer)) + + renderers = (MockJsonRenderer, ) + response = self.get_response(accept_list=accept_list, renderers=renderers) + renderer, media_type = response._determine_renderer() + self.assertEqual(media_type, 'application/json') + self.assertTrue(isinstance(renderer, MockJsonRenderer)) + + def test_determine_renderer_default(self): + """ + Test determine renderer when Accept was not specified. + """ + renderers = (MockPickleRenderer, ) + response = self.get_response(accept_list=None, renderers=renderers) + renderer, media_type = response._determine_renderer() + self.assertEqual(media_type, '*/*') + self.assertTrue(isinstance(renderer, MockPickleRenderer)) + + def test_determine_renderer_no_renderer(self): + """ + Test determine renderer when no renderer can satisfy the Accept list. + """ + accept_list = ['application/json'] + renderers = (MockPickleRenderer, ) + response = self.get_response(accept_list=accept_list, renderers=renderers) + self.assertRaises(NotAcceptable, response._determine_renderer) + + +class TestResponseRenderContent(TestCase): + def get_response(self, url='', accept_list=[], content=None, renderers=None): + request = RequestFactory().get(url, HTTP_ACCEPT=','.join(accept_list)) + return Response(request=request, content=content, renderers=renderers or DEFAULT_RENDERERS) + + def test_render(self): + """ + Test rendering simple data to json. + """ + content = {'a': 1, 'b': [1, 2, 3]} + content_type = 'application/json' + response = self.get_response(accept_list=[content_type], content=content) + response = response.render() + self.assertEqual(json.loads(response.content), content) + self.assertEqual(response['Content-Type'], content_type) + + def test_render_no_renderer(self): + """ + Test rendering response when no renderer can satisfy accept. + """ + content = 'bla' + content_type = 'weirdcontenttype' + response = self.get_response(accept_list=[content_type], content=content) + response = response.render() + self.assertEqual(response.status_code, 406) + self.assertIsNotNone(response.content) + + # def test_render_renderer_raises_ImmediateResponse(self): + # """ + # Test rendering response when renderer raises ImmediateResponse + # """ + # class PickyJSONRenderer(BaseRenderer): + # """ + # A renderer that doesn't make much sense, just to try + # out raising an ImmediateResponse + # """ + # media_type = 'application/json' + + # def render(self, obj=None, media_type=None): + # raise ImmediateResponse({'error': '!!!'}, status=400) + + # response = self.get_response( + # accept_list=['application/json'], + # renderers=[PickyJSONRenderer, JSONRenderer] + # ) + # response = response.render() + # self.assertEqual(response.status_code, 400) + # self.assertEqual(response.content, json.dumps({'error': '!!!'})) + + +DUMMYSTATUS = status.HTTP_200_OK +DUMMYCONTENT = 'dummycontent' + +RENDERER_A_SERIALIZER = lambda x: 'Renderer A: %s' % x +RENDERER_B_SERIALIZER = lambda x: 'Renderer B: %s' % x + + +class RendererA(BaseRenderer): + media_type = 'mock/renderera' + format = "formata" + + def render(self, obj=None, media_type=None): + return RENDERER_A_SERIALIZER(obj) + + +class RendererB(BaseRenderer): + media_type = 'mock/rendererb' + format = "formatb" + + def render(self, obj=None, media_type=None): + return RENDERER_B_SERIALIZER(obj) + + +class MockView(View): + renderers = (RendererA, RendererB) + + def get(self, request, **kwargs): + return Response(DUMMYCONTENT, status=DUMMYSTATUS) + + +class HTMLView(View): + renderers = (DocumentingHTMLRenderer, ) + + def get(self, request, **kwargs): + return Response('text') + + +class HTMLView1(View): + renderers = (DocumentingHTMLRenderer, JSONRenderer) + + def get(self, request, **kwargs): + return Response('text') + + +urlpatterns = patterns('', + url(r'^.*\.(?P.+)$', MockView.as_view(renderers=[RendererA, RendererB])), + url(r'^$', MockView.as_view(renderers=[RendererA, RendererB])), + url(r'^html$', HTMLView.as_view()), + url(r'^html1$', HTMLView1.as_view()), + url(r'^restframework', include('djangorestframework.urls', namespace='djangorestframework')) +) + + +# TODO: Clean tests bellow - remove duplicates with above, better unit testing, ... +class RendererIntegrationTests(TestCase): + """ + End-to-end testing of renderers using an ResponseMixin on a generic view. + """ + + urls = 'djangorestframework.tests.response' + + 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_head_method_serializes_no_content(self): + """No response must be included in HEAD requests.""" + resp = self.client.head('/') + self.assertEquals(resp.status_code, DUMMYSTATUS) + self.assertEquals(resp['Content-Type'], RendererA.media_type) + self.assertEquals(resp.content, '') + + 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_specified_renderer_serializes_content_on_accept_query(self): + """The '_accept' query string should behave in the same way as the Accept header.""" + resp = self.client.get('/?_accept=%s' % RendererB.media_type) + self.assertEquals(resp['Content-Type'], RendererB.media_type) + self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT)) + self.assertEquals(resp.status_code, DUMMYSTATUS) + + @unittest.skip('can\'t pass because view is a simple Django view and response is an ImmediateResponse') + 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, status.HTTP_406_NOT_ACCEPTABLE) + + def test_specified_renderer_serializes_content_on_format_query(self): + """If a 'format' query is specified, the renderer with the matching + format attribute should serialize the response.""" + resp = self.client.get('/?format=%s' % RendererB.format) + self.assertEquals(resp['Content-Type'], RendererB.media_type) + self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT)) + self.assertEquals(resp.status_code, DUMMYSTATUS) + + def test_specified_renderer_serializes_content_on_format_kwargs(self): + """If a 'format' keyword arg is specified, the renderer with the matching + format attribute should serialize the response.""" + resp = self.client.get('/something.formatb') + self.assertEquals(resp['Content-Type'], RendererB.media_type) + self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT)) + self.assertEquals(resp.status_code, DUMMYSTATUS) + + def test_specified_renderer_is_used_on_format_query_with_matching_accept(self): + """If both a 'format' query and a matching Accept header specified, + the renderer with the matching format attribute should serialize the response.""" + resp = self.client.get('/?format=%s' % RendererB.format, + HTTP_ACCEPT=RendererB.media_type) + self.assertEquals(resp['Content-Type'], RendererB.media_type) + self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT)) + self.assertEquals(resp.status_code, DUMMYSTATUS) + + def test_conflicting_format_query_and_accept_ignores_accept(self): + """If a 'format' query is specified that does not match the Accept + header, we should only honor the 'format' query string.""" + resp = self.client.get('/?format=%s' % RendererB.format, + HTTP_ACCEPT='dummy') + self.assertEquals(resp['Content-Type'], RendererB.media_type) + self.assertEquals(resp.content, RENDERER_B_SERIALIZER(DUMMYCONTENT)) + self.assertEquals(resp.status_code, DUMMYSTATUS) + + +class Issue122Tests(TestCase): + """ + Tests that covers #122. + """ + urls = 'djangorestframework.tests.response' + + def test_only_html_renderer(self): + """ + Test if no infinite recursion occurs. + """ + self.client.get('/html') + + def test_html_renderer_is_first(self): + """ + Test if no infinite recursion occurs. + """ + self.client.get('/html1') diff --git a/djangorestframework/tests/reverse.py b/djangorestframework/tests/reverse.py index 3dd13ef83..8d4675134 100644 --- a/djangorestframework/tests/reverse.py +++ b/djangorestframework/tests/reverse.py @@ -5,6 +5,7 @@ from django.utils import simplejson as json from djangorestframework.renderers import JSONRenderer from djangorestframework.reverse import reverse from djangorestframework.views import View +from djangorestframework.response import Response class MyView(View): @@ -15,7 +16,8 @@ class MyView(View): renderers = (JSONRenderer, ) def get(self, request): - return reverse('myview', request=request) + return Response(reverse('myview', request=request)) + urlpatterns = patterns('', url(r'^myview$', MyView.as_view(), name='myview'), diff --git a/djangorestframework/tests/throttling.py b/djangorestframework/tests/throttling.py index 7fdc6491a..73a4c02b1 100644 --- a/djangorestframework/tests/throttling.py +++ b/djangorestframework/tests/throttling.py @@ -10,19 +10,20 @@ from djangorestframework.compat import RequestFactory from djangorestframework.views import View from djangorestframework.permissions import PerUserThrottling, PerViewThrottling, PerResourceThrottling from djangorestframework.resources import FormResource +from djangorestframework.response import Response class MockView(View): - permissions = ( PerUserThrottling, ) + permissions_classes = ( PerUserThrottling, ) throttle = '3/sec' def get(self, request): - return 'foo' + return Response('foo') class MockView_PerViewThrottling(MockView): - permissions = ( PerViewThrottling, ) + permissions_classes = ( PerViewThrottling, ) class MockView_PerResourceThrottling(MockView): - permissions = ( PerResourceThrottling, ) + permissions_classes = ( PerResourceThrottling, ) resource = FormResource class MockView_MinuteThrottling(MockView): @@ -53,7 +54,7 @@ class ThrottlingTests(TestCase): """ Explicitly set the timer, overriding time.time() """ - view.permissions[0].timer = lambda self: value + view.permissions_classes[0].timer = lambda self: value def test_request_throttling_expires(self): """ diff --git a/djangorestframework/tests/validators.py b/djangorestframework/tests/validators.py index 15d92231c..bf2bf8b70 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.resources import FormResource, ModelResource -from djangorestframework.response import ErrorResponse +from djangorestframework.response import ImmediateResponse from djangorestframework.views import View @@ -81,10 +81,11 @@ class TestNonFieldErrors(TestCase): content = {'field1': 'example1', 'field2': 'example2'} try: MockResource(view).validate_request(content, None) - except ErrorResponse, exc: - self.assertEqual(exc.response.raw_content, {'errors': [MockForm.ERROR_TEXT]}) + except ImmediateResponse, exc: + response = exc.response + self.assertEqual(response.raw_content, {'errors': [MockForm.ERROR_TEXT]}) else: - self.fail('ErrorResponse was not raised') + self.fail('ImmediateResponse was not raised') class TestFormValidation(TestCase): @@ -120,14 +121,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(ErrorResponse, validator.validate_request, content, None) + self.assertRaises(ImmediateResponse, 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_request, content, None) + self.assertRaises(ImmediateResponse, 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.""" @@ -154,8 +155,9 @@ class TestFormValidation(TestCase): content = {} try: validator.validate_request(content, None) - except ErrorResponse, exc: - self.assertEqual(exc.response.raw_content, {'field_errors': {'qwerty': ['This field is required.']}}) + except ImmediateResponse, exc: + response = exc.response + self.assertEqual(response.raw_content, {'field_errors': {'qwerty': ['This field is required.']}}) else: self.fail('ResourceException was not raised') @@ -164,8 +166,9 @@ class TestFormValidation(TestCase): content = {'qwerty': ''} try: validator.validate_request(content, None) - except ErrorResponse, exc: - self.assertEqual(exc.response.raw_content, {'field_errors': {'qwerty': ['This field is required.']}}) + except ImmediateResponse, exc: + response = exc.response + self.assertEqual(response.raw_content, {'field_errors': {'qwerty': ['This field is required.']}}) else: self.fail('ResourceException was not raised') @@ -174,8 +177,9 @@ class TestFormValidation(TestCase): content = {'qwerty': 'uiop', 'extra': 'extra'} try: validator.validate_request(content, None) - except ErrorResponse, exc: - self.assertEqual(exc.response.raw_content, {'field_errors': {'extra': ['This field does not exist.']}}) + except ImmediateResponse, exc: + response = exc.response + self.assertEqual(response.raw_content, {'field_errors': {'extra': ['This field does not exist.']}}) else: self.fail('ResourceException was not raised') @@ -184,8 +188,9 @@ class TestFormValidation(TestCase): content = {'qwerty': '', 'extra': 'extra'} try: validator.validate_request(content, None) - except ErrorResponse, exc: - self.assertEqual(exc.response.raw_content, {'field_errors': {'qwerty': ['This field is required.'], + except ImmediateResponse, exc: + response = exc.response + self.assertEqual(response.raw_content, {'field_errors': {'qwerty': ['This field is required.'], 'extra': ['This field does not exist.']}}) else: self.fail('ResourceException was not raised') @@ -307,14 +312,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(ErrorResponse, self.validator.validate_request, content, None) + self.assertRaises(ImmediateResponse, 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_request, content, None) + self.assertRaises(ImmediateResponse, 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.""" diff --git a/djangorestframework/tests/views.py b/djangorestframework/tests/views.py index 418b4b164..00bce0021 100644 --- a/djangorestframework/tests/views.py +++ b/djangorestframework/tests/views.py @@ -2,16 +2,16 @@ from django.core.urlresolvers import reverse from django.conf.urls.defaults import patterns, url, include from django.http import HttpResponse from django.test import TestCase -from django.test import Client from django import forms from django.db import models +from django.utils import simplejson as json -from djangorestframework.views import View -from djangorestframework.parsers import JSONParser from djangorestframework.resources import ModelResource -from djangorestframework.views import ListOrCreateModelView, InstanceModelView - -from StringIO import StringIO +from djangorestframework.views import ( + View, + ListOrCreateModelView, + InstanceModelView +) class MockView(View): @@ -25,6 +25,7 @@ class MockViewFinal(View): def final(self, request, response, *args, **kwargs): return HttpResponse('{"test": "passed"}', content_type="application/json") + class ResourceMockView(View): """This is a resource-based mock view""" @@ -35,6 +36,7 @@ class ResourceMockView(View): form = MockForm + class MockResource(ModelResource): """This is a mock model-based resource""" @@ -55,6 +57,7 @@ urlpatterns = patterns('', url(r'^restframework/', include('djangorestframework.urls', namespace='djangorestframework')), ) + class BaseViewTests(TestCase): """Test the base view class of djangorestframework""" urls = 'djangorestframework.tests.views' @@ -62,8 +65,7 @@ class BaseViewTests(TestCase): def test_view_call_final(self): response = self.client.options('/mock/final/') self.assertEqual(response['Content-Type'].split(';')[0], "application/json") - parser = JSONParser(None) - (data, files) = parser.parse(StringIO(response.content)) + data = json.loads(response.content) self.assertEqual(data['test'], 'passed') def test_options_method_simple_view(self): @@ -77,9 +79,9 @@ class BaseViewTests(TestCase): self._verify_options_response(response, name='Resource Mock', description='This is a resource-based mock view', - fields={'foo':'BooleanField', - 'bar':'IntegerField', - 'baz':'CharField', + fields={'foo': 'BooleanField', + 'bar': 'IntegerField', + 'baz': 'CharField', }) def test_options_method_model_resource_list_view(self): @@ -87,9 +89,9 @@ class BaseViewTests(TestCase): self._verify_options_response(response, name='Mock List', description='This is a mock model-based resource', - fields={'foo':'BooleanField', - 'bar':'IntegerField', - 'baz':'CharField', + fields={'foo': 'BooleanField', + 'bar': 'IntegerField', + 'baz': 'CharField', }) def test_options_method_model_resource_detail_view(self): @@ -97,17 +99,16 @@ class BaseViewTests(TestCase): self._verify_options_response(response, name='Mock Instance', description='This is a mock model-based resource', - fields={'foo':'BooleanField', - 'bar':'IntegerField', - 'baz':'CharField', + fields={'foo': 'BooleanField', + 'bar': 'IntegerField', + 'baz': 'CharField', }) def _verify_options_response(self, response, name, description, fields=None, status=200, mime_type='application/json'): self.assertEqual(response.status_code, status) self.assertEqual(response['Content-Type'].split(';')[0], mime_type) - parser = JSONParser(None) - (data, files) = parser.parse(StringIO(response.content)) + data = json.loads(response.content) self.assertTrue('application/json' in data['renders']) self.assertEqual(name, data['name']) self.assertEqual(description, data['description']) @@ -132,6 +133,3 @@ class ExtraViewsTests(TestCase): response = self.client.get(reverse('djangorestframework:logout')) self.assertEqual(response.status_code, 200) self.assertEqual(response['Content-Type'].split(';')[0], 'text/html') - - # TODO: Add login/logout behaviour tests - diff --git a/djangorestframework/utils/__init__.py b/djangorestframework/utils/__init__.py index ca16b2617..ef611a16a 100644 --- a/djangorestframework/utils/__init__.py +++ b/djangorestframework/utils/__init__.py @@ -1,19 +1,17 @@ -import django from django.utils.encoding import smart_unicode from django.utils.xmlutils import SimplerXMLGenerator from django.core.urlresolvers import resolve -from django.conf import settings from djangorestframework.compat import StringIO +from djangorestframework.compat import RequestFactory as DjangoRequestFactory +from djangorestframework.request import Request import re import xml.etree.ElementTree as ET -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): """ Given an object which may be a list/tuple, another object, or None, @@ -55,24 +53,23 @@ class XML2Dict(object): # Save attrs and text, hope there will not be a child with same name if node.text: node_tree = node.text - for (k,v) in node.attrib.items(): - k,v = self._namespace_split(k, v) + for (k, v) in node.attrib.items(): + k, v = self._namespace_split(k, v) node_tree[k] = v #Save childrens for child in node.getchildren(): tag, tree = self._namespace_split(child.tag, self._parse_node(child)) - if tag not in node_tree: # the first time, so store it in dict + if tag not in node_tree: # the first time, so store it in dict node_tree[tag] = tree continue old = node_tree[tag] if not isinstance(old, list): node_tree.pop(tag) - node_tree[tag] = [old] # multi times, so change old dict to a list - node_tree[tag].append(tree) # add the new one + node_tree[tag] = [old] # multi times, so change old dict to a list + node_tree[tag].append(tree) # add the new one return node_tree - def _namespace_split(self, tag, value): """ Split the tag '{http://cs.sfsu.edu/csc867/myscheduler}patients' @@ -135,5 +132,41 @@ class XMLRenderer(): xml.endDocument() return stream.getvalue() + def dict2xml(input): return XMLRenderer().dict2xml(input) + + +class RequestFactory(DjangoRequestFactory): + """ + Replicate RequestFactory, but return Request, not HttpRequest. + """ + def get(self, *args, **kwargs): + parsers = kwargs.pop('parsers', None) + request = super(RequestFactory, self).get(*args, **kwargs) + return Request(request, parsers) + + def post(self, *args, **kwargs): + parsers = kwargs.pop('parsers', None) + request = super(RequestFactory, self).post(*args, **kwargs) + return Request(request, parsers) + + def put(self, *args, **kwargs): + parsers = kwargs.pop('parsers', None) + request = super(RequestFactory, self).put(*args, **kwargs) + return Request(request, parsers) + + def delete(self, *args, **kwargs): + parsers = kwargs.pop('parsers', None) + request = super(RequestFactory, self).delete(*args, **kwargs) + return Request(request, parsers) + + def head(self, *args, **kwargs): + parsers = kwargs.pop('parsers', None) + request = super(RequestFactory, self).head(*args, **kwargs) + return Request(request, parsers) + + def options(self, *args, **kwargs): + parsers = kwargs.pop('parsers', None) + request = super(RequestFactory, self).options(*args, **kwargs) + return Request(request, parsers) diff --git a/djangorestframework/views.py b/djangorestframework/views.py index 3657fd645..8fe5f99a6 100644 --- a/djangorestframework/views.py +++ b/djangorestframework/views.py @@ -6,13 +6,12 @@ By setting or modifying class attributes on your view, you change it's predefine """ import re -from django.http import HttpResponse from django.utils.html import escape from django.utils.safestring import mark_safe from django.views.decorators.csrf import csrf_exempt from djangorestframework.compat import View as DjangoView, apply_markdown -from djangorestframework.response import Response, ErrorResponse +from djangorestframework.response import Response, ImmediateResponse from djangorestframework.mixins import * from djangorestframework import resources, renderers, parsers, authentication, permissions, status @@ -68,7 +67,7 @@ _resource_classes = ( ) -class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): +class View(ResourceMixin, RequestMixin, ResponseMixin, PermissionsMixin, DjangoView): """ Handles incoming requests and maps them to REST operations. Performs request deserialization, response serialization, authentication and input validation. @@ -82,16 +81,16 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): renderers = renderers.DEFAULT_RENDERERS """ - List of renderers the resource can serialize the response with, ordered by preference. + List of renderer classes the resource can serialize the response with, ordered by preference. """ parsers = parsers.DEFAULT_PARSERS """ - List of parsers the resource can parse the request with. + List of parser classes the resource can parse the request with. """ authentication = (authentication.UserLoggedInAuthentication, - authentication.BasicAuthentication) + authentication.BasicAuthentication) """ List of all authenticating methods to attempt. """ @@ -117,7 +116,15 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): """ Return the list of allowed HTTP methods, uppercased. """ - 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 hasattr(self, method)] + + @property + def default_response_headers(self): + return { + 'Allow': ', '.join(self.allowed_methods), + 'Vary': 'Authenticate, Accept' + } def get_name(self): """ @@ -161,6 +168,9 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): return description def markup_description(self, description): + """ + Apply HTML markup to the description of this view. + """ if apply_markdown: description = apply_markdown(description) else: @@ -169,81 +179,73 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): 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}) + content = { + 'detail': "Method '%s' not allowed on this resource." % request.method + } + raise ImmediateResponse(content, status.HTTP_405_METHOD_NOT_ALLOWED) 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. + This method is a 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 def final(self, request, response, *args, **kargs): """ - Hook for any code that needs to run after everything else in the view. + This method is a hook for any code that needs to run after everything + else in the view. + Returns the final response object. """ - # Always add these headers. - response.headers['Allow'] = ', '.join(self.allowed_methods) - # sample to allow caching using Vary http header - response.headers['Vary'] = 'Authenticate, Accept' - - # merge with headers possibly set at some point in the view - response.headers.update(self.headers) - return self.render(response) - - def add_header(self, field, value): - """ - Add *field* and *value* to the :attr:`headers` attribute of the :class:`View` class. - """ - self.headers[field] = value + response.view = self + response.request = request + response.renderers = self.renderers + for key, value in self.headers.items(): + response[key] = value + return response # Note: session based authentication is explicitly CSRF validated, # all other authentication is CSRF exempt. @csrf_exempt def dispatch(self, request, *args, **kwargs): + request = self.create_request(request) self.request = request + self.args = args self.kwargs = kwargs - self.headers = {} + self.headers = self.default_response_headers try: self.initial(request, *args, **kwargs) - # Authenticate and check request has the relevant permissions - self._check_permissions() + # check that user has the relevant permissions + self.check_permissions(request.user) # 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 request.method.lower() in self.http_method_names: + handler = getattr(self, request.method.lower(), self.http_method_not_allowed) else: handler = self.http_method_not_allowed - response_obj = handler(request, *args, **kwargs) + response = 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) + if isinstance(response, Response): + # Pre-serialize filtering (eg filter complex objects into natively serializable types) + response.raw_content = self.filter_response(response.raw_content) - # Pre-serialize filtering (eg filter complex objects into natively serializable types) - response.cleaned_content = self.filter_response(response.raw_content) - - except ErrorResponse, exc: + except ImmediateResponse, exc: response = exc.response - return self.final(request, response, *args, **kwargs) + self.response = self.final(request, response, *args, **kwargs) + return self.response def options(self, request, *args, **kwargs): - response_obj = { + content = { 'name': self.get_name(), 'description': self.get_description(), 'renders': self._rendered_media_types, @@ -254,11 +256,8 @@ class View(ResourceMixin, RequestMixin, ResponseMixin, AuthMixin, DjangoView): field_name_types = {} for name, field in form.fields.iteritems(): field_name_types[name] = field.__class__.__name__ - response_obj['fields'] = field_name_types - # Note 'ErrorResponse' is misleading, it's just any response - # that should be rendered and returned immediately, without any - # response filtering. - raise ErrorResponse(status.HTTP_200_OK, response_obj) + content['fields'] = field_name_types + raise ImmediateResponse(content, status=status.HTTP_200_OK) class ModelView(View): diff --git a/docs/howto/requestmixin.rst b/docs/howto/requestmixin.rst new file mode 100644 index 000000000..c0aadb3f7 --- /dev/null +++ b/docs/howto/requestmixin.rst @@ -0,0 +1,75 @@ +Using the enhanced request in all your views +============================================== + +This example shows how you can use Django REST framework's enhanced `request` - :class:`request.Request` - in your own views, without having to use the full-blown :class:`views.View` class. + +What can it do for you ? Mostly, it will take care of parsing the request's content, and handling equally all HTTP methods ... + +Before +-------- + +In order to support `JSON` or other serial formats, you might have parsed manually the request's content with something like : :: + + class MyView(View): + + def put(self, request, *args, **kwargs): + content_type = request.META['CONTENT_TYPE'] + if (content_type == 'application/json'): + raw_data = request.read() + parsed_data = json.loads(raw_data) + + # PLUS as many `elif` as formats you wish to support ... + + # then do stuff with your data : + self.do_stuff(parsed_data['bla'], parsed_data['hoho']) + + # and finally respond something + +... and you were unhappy because this looks hackish. + +Also, you might have tried uploading files with a PUT request - *and given up* since that's complicated to achieve even with Django 1.3. + + +After +------ + +All the dirty `Content-type` checking and content reading and parsing is done for you, and you only need to do the following : :: + + class MyView(MyBaseViewUsingEnhancedRequest): + + def put(self, request, *args, **kwargs): + self.do_stuff(request.DATA['bla'], request.DATA['hoho']) + # and finally respond something + +So the parsed content is magically available as `.DATA` on the `request` object. + +Also, if you uploaded files, they are available as `.FILES`, like with a normal POST request. + +.. note:: Note that all the above is also valid for a POST request. + + +How to add it to your custom views ? +-------------------------------------- + +Now that you're convinced you need to use the enhanced request object, here is how you can add it to all your custom views : :: + + from django.views.generic.base import View + + from djangorestframework.mixins import RequestMixin + from djangorestframework import parsers + + + class MyBaseViewUsingEnhancedRequest(RequestMixin, View): + """ + Base view enabling the usage of enhanced requests with user defined views. + """ + + parser_classes = parsers.DEFAULT_PARSERS + + def dispatch(self, request, *args, **kwargs): + request = self.prepare_request(request) + return super(MyBaseViewUsingEnhancedRequest, self).dispatch(request, *args, **kwargs) + +And then, use this class as a base for all your custom views. + +.. note:: you can see this live in the examples. diff --git a/docs/library/request.rst b/docs/library/request.rst new file mode 100644 index 000000000..5e99826ad --- /dev/null +++ b/docs/library/request.rst @@ -0,0 +1,5 @@ +:mod:`request` +===================== + +.. automodule:: request + :members: diff --git a/docs/library/utils.rst b/docs/library/utils.rst new file mode 100644 index 000000000..653f24fde --- /dev/null +++ b/docs/library/utils.rst @@ -0,0 +1,5 @@ +:mod:`utils` +============== + +.. automodule:: utils + :members: diff --git a/examples/blogpost/resources.py b/examples/blogpost/resources.py index c3166bcf3..b3659cdfb 100644 --- a/examples/blogpost/resources.py +++ b/examples/blogpost/resources.py @@ -18,8 +18,8 @@ class BlogPostResource(ModelResource): def comments(self, instance): return reverse('comments', - kwargs={'blogpost': instance.key}, - request=self.request) + kwargs={'blogpost': instance.key}, + request=self.request) class CommentResource(ModelResource): diff --git a/examples/objectstore/views.py b/examples/objectstore/views.py index 880bd3fcb..a8889cd87 100644 --- a/examples/objectstore/views.py +++ b/examples/objectstore/views.py @@ -57,7 +57,9 @@ class ObjectStoreRoot(View): 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 [get_file_url(key, request) for key in ctime_sorted_basenames] + content = [get_file_url(key, request) + for key in ctime_sorted_basenames] + return Response(content) def post(self, request): """ @@ -69,7 +71,7 @@ class ObjectStoreRoot(View): remove_oldest_files(OBJECT_STORE_DIR, MAX_FILES) url = get_file_url(key, request) - return Response(status.HTTP_201_CREATED, self.CONTENT, {'Location': url}) + return Response(self.CONTENT, status.HTTP_201_CREATED, {'Location': url}) class StoredObject(View): @@ -84,8 +86,8 @@ class StoredObject(View): """ filename = get_filename(key) if not os.path.exists(filename): - return Response(status.HTTP_404_NOT_FOUND) - return pickle.load(open(filename, 'rb')) + return Response(status=status.HTTP_404_NOT_FOUND) + return Response(pickle.load(open(filename, 'rb'))) def put(self, request, key): """ @@ -94,7 +96,7 @@ class StoredObject(View): """ filename = get_filename(key) pickle.dump(self.CONTENT, open(filename, 'wb')) - return self.CONTENT + return Response(self.CONTENT) def delete(self, request, key): """ @@ -102,5 +104,6 @@ class StoredObject(View): """ filename = get_filename(key) if not os.path.exists(filename): - return Response(status.HTTP_404_NOT_FOUND) + return Response(status=status.HTTP_404_NOT_FOUND) os.remove(filename) + return Response() diff --git a/examples/permissionsexample/tests.py b/examples/permissionsexample/tests.py new file mode 100644 index 000000000..5434437af --- /dev/null +++ b/examples/permissionsexample/tests.py @@ -0,0 +1,27 @@ +from django.test import TestCase +from django.core.urlresolvers import reverse +from django.test.client import Client + + +class NaviguatePermissionsExamples(TestCase): + """ + Sanity checks for permissions examples + """ + + def test_throttled_resource(self): + url = reverse('throttled-resource') + for i in range(0, 10): + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + response = self.client.get(url) + self.assertEqual(response.status_code, 503) + + + def test_loggedin_resource(self): + url = reverse('loggedin-resource') + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + loggedin_client = Client() + loggedin_client.login(username='test', password='test') + response = loggedin_client.get(url) + self.assertEqual(response.status_code, 200) diff --git a/examples/permissionsexample/views.py b/examples/permissionsexample/views.py index 5aeae7eb2..f3dafcd47 100644 --- a/examples/permissionsexample/views.py +++ b/examples/permissionsexample/views.py @@ -1,4 +1,5 @@ from djangorestframework.views import View +from djangorestframework.response import Response from djangorestframework.permissions import PerUserThrottling, IsAuthenticated from djangorestframework.reverse import reverse @@ -9,16 +10,16 @@ class PermissionsExampleView(View): """ def get(self, request): - return [ + return Response([ { 'name': 'Throttling Example', - 'url': reverse('throttled-resource', request=request) + 'url': reverse('throttled-resource', request) }, { 'name': 'Logged in example', - 'url': reverse('loggedin-resource', request=request) + 'url': reverse('loggedin-resource', request) }, - ] + ]) class ThrottlingExampleView(View): @@ -29,14 +30,14 @@ class ThrottlingExampleView(View): throttle will be applied until 60 seconds have passed since the first request. """ - permissions = (PerUserThrottling,) + permissions_classes = (PerUserThrottling,) throttle = '10/min' def get(self, request): """ Handle GET requests. """ - return "Successful response to GET request because throttle is not yet active." + return Response("Successful response to GET request because throttle is not yet active.") class LoggedInExampleView(View): @@ -46,7 +47,7 @@ class LoggedInExampleView(View): `curl -X GET -H 'Accept: application/json' -u test:test http://localhost:8000/permissions-example` """ - permissions = (IsAuthenticated, ) + permissions_classes = (IsAuthenticated, ) def get(self, request): - return 'You have permission to view this resource' + return Response('You have permission to view this resource') diff --git a/examples/pygments_api/forms.py b/examples/pygments_api/forms.py index 30a59a845..cc147740c 100644 --- a/examples/pygments_api/forms.py +++ b/examples/pygments_api/forms.py @@ -6,6 +6,7 @@ from pygments.styles import get_all_styles LEXER_CHOICES = sorted([(item[1][0], item[0]) for item in get_all_lexers()]) STYLE_CHOICES = sorted((item, item) for item in list(get_all_styles())) + class PygmentsForm(forms.Form): """A simple form with some of the most important pygments settings. The code to be highlighted can be specified either in a text field, or by URL. @@ -24,5 +25,3 @@ class PygmentsForm(forms.Form): initial='python') style = forms.ChoiceField(choices=STYLE_CHOICES, initial='friendly') - - diff --git a/examples/pygments_api/tests.py b/examples/pygments_api/tests.py index 247266476..b728c3c29 100644 --- a/examples/pygments_api/tests.py +++ b/examples/pygments_api/tests.py @@ -14,13 +14,13 @@ class TestPygmentsExample(TestCase): self.factory = RequestFactory() self.temp_dir = tempfile.mkdtemp() views.HIGHLIGHTED_CODE_DIR = self.temp_dir - + def tearDown(self): try: shutil.rmtree(self.temp_dir) except Exception: pass - + def test_get_to_root(self): '''Just do a get on the base url''' request = self.factory.get('/pygments') @@ -44,6 +44,3 @@ class TestPygmentsExample(TestCase): response = view(request) response_locations = json.loads(response.content) self.assertEquals(locations, response_locations) - - - diff --git a/examples/pygments_api/views.py b/examples/pygments_api/views.py index f41fa739e..a3812ef44 100644 --- a/examples/pygments_api/views.py +++ b/examples/pygments_api/views.py @@ -1,7 +1,6 @@ from __future__ import with_statement # for python 2.5 from django.conf import settings -from djangorestframework.resources import FormResource from djangorestframework.response import Response from djangorestframework.renderers import BaseRenderer from djangorestframework.reverse import reverse @@ -64,8 +63,11 @@ class PygmentsRoot(View): """ 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', request=request, args=[unique_id]) for unique_id in unique_ids] + unique_ids = [os.path.split(f)[1] + for f in list_dir_sorted_by_ctime(HIGHLIGHTED_CODE_DIR)] + urls = [reverse('pygments-instance', args=[unique_id], request=request) + for unique_id in unique_ids] + return Response(urls) def post(self, request): """ @@ -85,7 +87,8 @@ class PygmentsRoot(View): remove_oldest_files(HIGHLIGHTED_CODE_DIR, MAX_FILES) - return Response(status.HTTP_201_CREATED, headers={'Location': reverse('pygments-instance', request=request, args=[unique_id])}) + location = reverse('pygments-instance', args=[unique_id], request=request) + return Response(status=status.HTTP_201_CREATED, headers={'Location': location}) class PygmentsInstance(View): @@ -93,7 +96,7 @@ 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. """ - renderers = (HTMLRenderer,) + renderers = (HTMLRenderer, ) def get(self, request, unique_id): """ @@ -101,8 +104,8 @@ class PygmentsInstance(View): """ 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() + return Response(status=status.HTTP_404_NOT_FOUND) + return Response(open(pathname, 'r').read()) def delete(self, request, unique_id): """ @@ -110,6 +113,6 @@ class PygmentsInstance(View): """ pathname = os.path.join(HIGHLIGHTED_CODE_DIR, unique_id) if not os.path.exists(pathname): - return Response(status.HTTP_404_NOT_FOUND) - return os.remove(pathname) - + return Response(status=status.HTTP_404_NOT_FOUND) + os.remove(pathname) + return Response() diff --git a/examples/requestexample/__init__.py b/examples/requestexample/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/requestexample/models.py b/examples/requestexample/models.py new file mode 100644 index 000000000..71a836239 --- /dev/null +++ b/examples/requestexample/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/examples/requestexample/tests.py b/examples/requestexample/tests.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/requestexample/urls.py b/examples/requestexample/urls.py new file mode 100644 index 000000000..d644a5992 --- /dev/null +++ b/examples/requestexample/urls.py @@ -0,0 +1,9 @@ +from django.conf.urls.defaults import patterns, url +from requestexample.views import RequestExampleView, EchoRequestContentView +from examples.views import ProxyView + + +urlpatterns = patterns('', + url(r'^$', RequestExampleView.as_view(), name='request-example'), + url(r'^content$', ProxyView.as_view(view_class=EchoRequestContentView), name='request-content'), +) diff --git a/examples/requestexample/views.py b/examples/requestexample/views.py new file mode 100644 index 000000000..2036d6cdd --- /dev/null +++ b/examples/requestexample/views.py @@ -0,0 +1,43 @@ +from djangorestframework.compat import View +from django.http import HttpResponse +from django.core.urlresolvers import reverse + +from djangorestframework.mixins import RequestMixin +from djangorestframework.views import View as DRFView +from djangorestframework import parsers +from djangorestframework.response import Response + + +class RequestExampleView(DRFView): + """ + A container view for request examples. + """ + + def get(self, request): + return Response([{'name': 'request.DATA Example', 'url': reverse('request-content')},]) + + +class MyBaseViewUsingEnhancedRequest(RequestMixin, View): + """ + Base view enabling the usage of enhanced requests with user defined views. + """ + + parsers = parsers.DEFAULT_PARSERS + + def dispatch(self, request, *args, **kwargs): + self.request = request = self.create_request(request) + return super(MyBaseViewUsingEnhancedRequest, self).dispatch(request, *args, **kwargs) + + +class EchoRequestContentView(MyBaseViewUsingEnhancedRequest): + """ + A view that just reads the items in `request.DATA` and echoes them back. + """ + + def post(self, request, *args, **kwargs): + return HttpResponse(("Found %s in request.DATA, content : %s" % + (type(request.DATA), request.DATA))) + + def put(self, request, *args, **kwargs): + return HttpResponse(("Found %s in request.DATA, content : %s" % + (type(request.DATA), request.DATA))) diff --git a/examples/resourceexample/forms.py b/examples/resourceexample/forms.py index aa6e7685a..d21d601aa 100644 --- a/examples/resourceexample/forms.py +++ b/examples/resourceexample/forms.py @@ -1,5 +1,6 @@ from django import forms + class MyForm(forms.Form): foo = forms.BooleanField(required=False) bar = forms.IntegerField(help_text='Must be an integer.') diff --git a/examples/resourceexample/views.py b/examples/resourceexample/views.py index 0db238044..41a3111c6 100644 --- a/examples/resourceexample/views.py +++ b/examples/resourceexample/views.py @@ -13,18 +13,19 @@ class ExampleView(View): def get(self, request): """ - Handle GET requests, returning a list of URLs pointing to 3 other views. + Handle GET requests, returning a list of URLs pointing to + three other views. """ resource_urls = [reverse('another-example', kwargs={'num': num}, request=request) for num in range(3)] - return {"Some other resources": resource_urls} + return Response({"Some other resources": resource_urls}) class AnotherExampleView(View): """ - A basic view, that can be handle GET and POST requests. + A basic view, that can handle GET and POST requests. Applies some simple form validation on POST requests. """ form = MyForm @@ -35,8 +36,8 @@ class AnotherExampleView(View): Returns a simple string indicating which view the GET request was for. """ if int(num) > 2: - return Response(status.HTTP_404_NOT_FOUND) - return "GET request to AnotherExampleResource %s" % num + return Response(status=status.HTTP_404_NOT_FOUND) + return Response("GET request to AnotherExampleResource %s" % num) def post(self, request, num): """ @@ -44,5 +45,5 @@ class AnotherExampleView(View): Returns a simple string indicating what content was supplied. """ if int(num) > 2: - return Response(status.HTTP_404_NOT_FOUND) - return "POST request to AnotherExampleResource %s, with content: %s" % (num, repr(self.CONTENT)) + return Response(status=status.HTTP_404_NOT_FOUND) + return Response("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 8e3b3a10a..f4de29474 100644 --- a/examples/sandbox/views.py +++ b/examples/sandbox/views.py @@ -2,36 +2,66 @@ from djangorestframework.reverse import reverse from djangorestframework.views import View +from djangorestframework.response import Response class Sandbox(View): - """This is the sandbox for the examples provided with [Django REST framework](http://django-rest-framework.org). + """ + This is the sandbox for the examples provided with + [Django REST framework][1]. - These examples are provided to help you get a better idea of some of the features of RESTful APIs created using the framework. + These examples are provided to help you get a better idea of some of the + features of RESTful APIs created using the framework. - All the example APIs allow anonymous access, and can be navigated either through the browser or from the command line... + 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 renderer) - bash: curl -X GET http://api.django-rest-framework.org/ -H 'Accept: text/plain' # (Use plaintext documentation renderer) + For example, to get the default representation using curl: + + bash: curl -X GET http://rest.ep.io/ + + Or, to get the plaintext documentation represention: + + bash: curl -X GET http://rest.ep.io/ -H 'Accept: text/plain' 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 [RendererMixin](http://django-rest-framework.org/library/renderers.html). + 1. A basic example using the [Resource][2] class. + 2. A basic example using the [ModelResource][3] class. + 3. An basic example using Django 1.3's [class based views][4] and + djangorestframework's [RendererMixin][5]. 4. A generic object store API. 5. A code highlighting API. 6. A blog posts and comments API. 7. A basic example using permissions. + 8. A basic example using enhanced request. - Please feel free to browse, create, edit and delete the resources in these examples.""" + Please feel free to browse, create, edit and delete the resources in + these examples. + + [1]: http://django-rest-framework.org + [2]: http://django-rest-framework.org/library/resource.html + [3]: http://django-rest-framework.org/library/modelresource.html + [4]: http://docs.djangoproject.com/en/dev/topics/class-based-views/ + [5]: http://django-rest-framework.org/library/renderers.html + """ def get(self, request): - return [{'name': 'Simple Resource example', 'url': reverse('example-resource', request=request)}, - {'name': 'Simple ModelResource example', 'url': reverse('model-resource-root', request=request)}, - {'name': 'Simple Mixin-only example', 'url': reverse('mixin-view', request=request)}, - {'name': 'Object store API', 'url': reverse('object-store-root', request=request)}, - {'name': 'Code highlighting API', 'url': reverse('pygments-root', request=request)}, - {'name': 'Blog posts API', 'url': reverse('blog-posts-root', request=request)}, - {'name': 'Permissions example', 'url': reverse('permissions-example', request=request)} - ] + return Response([ + {'name': 'Simple Resource example', + 'url': reverse('example-resource', request=request)}, + {'name': 'Simple ModelResource example', + 'url': reverse('model-resource-root', request=request)}, + {'name': 'Simple Mixin-only example', + 'url': reverse('mixin-view', request=request)}, + {'name': 'Object store API', + 'url': reverse('object-store-root', request=request)}, + {'name': 'Code highlighting API', + 'url': reverse('pygments-root', request=request)}, + {'name': 'Blog posts API', + 'url': reverse('blog-posts-root', request=request)}, + {'name': 'Permissions example', + 'url': reverse('permissions-example', request=request)}, + {'name': 'Simple request mixin example', + 'url': reverse('request-example', request=request)} + ]) diff --git a/examples/settings.py b/examples/settings.py index 5ff9fd0f3..d7da6b0a1 100644 --- a/examples/settings.py +++ b/examples/settings.py @@ -106,6 +106,7 @@ INSTALLED_APPS = ( 'pygments_api', 'blogpost', 'permissionsexample', + 'requestexample', ) import os diff --git a/examples/views.py b/examples/views.py new file mode 100644 index 000000000..e0e4c3c40 --- /dev/null +++ b/examples/views.py @@ -0,0 +1,32 @@ +from djangorestframework.views import View +from djangorestframework.response import Response + + +class ProxyView(View): + """ + A view that just acts as a proxy to call non-djangorestframework views, while still + displaying the browsable API interface. + """ + + view_class = None + + def dispatch(self, request, *args, **kwargs): + self.request = request = self.create_request(request) + if request.method in ['PUT', 'POST']: + self.response = self.view_class.as_view()(request, *args, **kwargs) + return super(ProxyView, self).dispatch(request, *args, **kwargs) + + def get(self, request, *args, **kwargs): + return Response() + + def put(self, request, *args, **kwargs): + return Response(self.response.content) + + def post(self, request, *args, **kwargs): + return Response(self.response.content) + + def get_name(self): + return self.view_class.__name__ + + def get_description(self, html): + return self.view_class.__doc__