diff --git a/djangorestframework/emitters.py b/djangorestframework/emitters.py index 36feea05d..2990d313e 100644 --- a/djangorestframework/emitters.py +++ b/djangorestframework/emitters.py @@ -4,11 +4,10 @@ by serializing the output along with documentation regarding the Resource, outpu and providing forms and links depending on the allowed methods, emitters and parsers on the Resource. """ from django.conf import settings -from django.http import HttpResponse from django.template import RequestContext, loader from django import forms -from djangorestframework.response import NoContent, ResponseException +from djangorestframework.response import ErrorResponse from djangorestframework.utils import dict2xml, url_resolves from djangorestframework.markdownwrapper import apply_markdown from djangorestframework.breadcrumbs import get_breadcrumbs @@ -18,7 +17,6 @@ from djangorestframework import status from urllib import quote_plus import string import re -from decimal import Decimal try: import json @@ -26,132 +24,9 @@ except ImportError: import simplejson as json -_MSIE_USER_AGENT = re.compile(r'^Mozilla/[0-9]+\.[0-9]+ \([^)]*; MSIE [0-9]+\.[0-9]+[a-z]?;[^)]*\)(?!.* Opera )') - - -class EmitterMixin(object): - """Adds behaviour for pluggable Emitters to a :class:`.Resource` or Django :class:`View`. class. - - Default behaviour is to use standard HTTP Accept header content negotiation. - Also supports overidding the content type by specifying an _accept= parameter in the URL. - Ignores Accept headers from Internet Explorer user agents and uses a sensible browser Accept header instead.""" - - ACCEPT_QUERY_PARAM = '_accept' # Allow override of Accept header in URL query params - REWRITE_IE_ACCEPT_HEADER = True - - request = None - response = None - emitters = () - - def emit(self, response): - """Takes a :class:`Response` object and returns a Django :class:`HttpResponse`.""" - self.response = response - - try: - emitter = self._determine_emitter(self.request) - except ResponseException, exc: - emitter = self.default_emitter - response = exc.response - - # Serialize the response content - if response.has_content_body: - content = emitter(self).emit(output=response.cleaned_content) - else: - content = emitter(self).emit() - - # Munge DELETE Response code to allow us to return content - # (Do this *after* we've rendered the template so that we include the normal deletion response code in the output) - if response.status == 204: - response.status = 200 - - # Build the HTTP Response - # TODO: Check if emitter.mimetype is underspecified, or if a content-type header has been set - resp = HttpResponse(content, mimetype=emitter.media_type, status=response.status) - for (key, val) in response.headers.items(): - resp[key] = val - - return resp - - - def _determine_emitter(self, request): - """Return the appropriate emitter for the output, given the client's 'Accept' header, - and the content types that this Resource knows how to serve. - - See: RFC 2616, Section 14 - http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html""" - - if self.ACCEPT_QUERY_PARAM and request.GET.get(self.ACCEPT_QUERY_PARAM, None): - # Use _accept parameter override - accept_list = [request.GET.get(self.ACCEPT_QUERY_PARAM)] - elif self.REWRITE_IE_ACCEPT_HEADER and request.META.has_key('HTTP_USER_AGENT') and _MSIE_USER_AGENT.match(request.META['HTTP_USER_AGENT']): - accept_list = ['text/html', '*/*'] - elif request.META.has_key('HTTP_ACCEPT'): - # Use standard HTTP Accept negotiation - accept_list = request.META["HTTP_ACCEPT"].split(',') - else: - # No accept header specified - return self.default_emitter - - # Parse the accept header into a dict of {qvalue: set of media types} - # We ignore mietype parameters - accept_dict = {} - for token in accept_list: - components = token.split(';') - mimetype = components[0].strip() - qvalue = Decimal('1.0') - - if len(components) > 1: - # Parse items that have a qvalue eg text/html;q=0.9 - try: - (q, num) = components[-1].split('=') - if q == 'q': - qvalue = Decimal(num) - except: - # Skip malformed entries - continue - - if accept_dict.has_key(qvalue): - accept_dict[qvalue].add(mimetype) - else: - accept_dict[qvalue] = set((mimetype,)) - - # Convert to a list of sets ordered by qvalue (highest first) - accept_sets = [accept_dict[qvalue] for qvalue in sorted(accept_dict.keys(), reverse=True)] - - for accept_set in accept_sets: - # Return any exact match - for emitter in self.emitters: - if emitter.media_type in accept_set: - return emitter - - # Return any subtype match - for emitter in self.emitters: - if emitter.media_type.split('/')[0] + '/*' in accept_set: - return emitter - - # Return default - if '*/*' in accept_set: - return self.default_emitter - - - raise ResponseException(status.HTTP_406_NOT_ACCEPTABLE, - {'detail': 'Could not statisfy the client\'s Accept header', - 'available_types': self.emitted_media_types}) - - @property - def emitted_media_types(self): - """Return an list of all the media types that this resource can emit.""" - return [emitter.media_type for emitter in self.emitters] - - @property - def default_emitter(self): - """Return the resource's most prefered emitter. - (This emitter is used if the client does not send and Accept: header, or sends Accept: */*)""" - return self.emitters[0] - - # TODO: Rename verbose to something more appropriate -# TODO: NoContent could be handled more cleanly. It'd be nice if it was handled by default, +# TODO: Maybe None could be handled more cleanly. It'd be nice if it was handled by default, # and only have an emitter output anything if it explicitly provides support for that. class BaseEmitter(object): @@ -162,10 +37,10 @@ class BaseEmitter(object): def __init__(self, resource): self.resource = resource - def emit(self, output=NoContent, verbose=False): + def emit(self, output=None, verbose=False): """By default emit simply returns the ouput as-is. Override this method to provide for other behaviour.""" - if output is NoContent: + if output is None: return '' return output @@ -177,8 +52,8 @@ class TemplateEmitter(BaseEmitter): media_type = None template = None - def emit(self, output=NoContent, verbose=False): - if output is NoContent: + def emit(self, output=None, verbose=False): + if output is None: return '' context = RequestContext(self.request, output) @@ -276,7 +151,7 @@ class DocumentingTemplateEmitter(BaseEmitter): return GenericContentForm(resource) - def emit(self, output=NoContent): + def emit(self, output=None): content = self._get_content(self.resource, self.resource.request, output) form_instance = self._get_form_instance(self.resource) @@ -324,8 +199,8 @@ class JSONEmitter(BaseEmitter): """Emitter which serializes to JSON""" media_type = 'application/json' - def emit(self, output=NoContent, verbose=False): - if output is NoContent: + def emit(self, output=None, verbose=False): + if output is None: return '' if verbose: return json.dumps(output, indent=4, sort_keys=True) @@ -336,8 +211,8 @@ class XMLEmitter(BaseEmitter): """Emitter which serializes to XML.""" media_type = 'application/xml' - def emit(self, output=NoContent, verbose=False): - if output is NoContent: + def emit(self, output=None, verbose=False): + if output is None: return '' return dict2xml(output) diff --git a/djangorestframework/modelresource.py b/djangorestframework/modelresource.py index 7fa370532..b0a4b1c12 100644 --- a/djangorestframework/modelresource.py +++ b/djangorestframework/modelresource.py @@ -3,7 +3,7 @@ from django.db.models import Model from django.db.models.query import QuerySet from django.db.models.fields.related import RelatedField -from djangorestframework.response import Response, ResponseException +from djangorestframework.response import Response, ErrorResponse from djangorestframework.resource import Resource from djangorestframework import status, validators @@ -370,7 +370,7 @@ class ModelResource(Resource): # Otherwise assume the kwargs uniquely identify the model instance = self.model.objects.get(**kwargs) except self.model.DoesNotExist: - raise ResponseException(status.HTTP_404_NOT_FOUND) + raise ErrorResponse(status.HTTP_404_NOT_FOUND) return instance @@ -402,7 +402,7 @@ class ModelResource(Resource): # Otherwise assume the kwargs uniquely identify the model instance = self.model.objects.get(**kwargs) except self.model.DoesNotExist: - raise ResponseException(status.HTTP_404_NOT_FOUND, None, {}) + raise ErrorResponse(status.HTTP_404_NOT_FOUND, None, {}) instance.delete() return diff --git a/djangorestframework/parsers.py b/djangorestframework/parsers.py index 11adeb78a..707b61d5d 100644 --- a/djangorestframework/parsers.py +++ b/djangorestframework/parsers.py @@ -9,7 +9,7 @@ We need a method to be able to: and multipart/form-data. (eg also handle multipart/json) """ from django.http.multipartparser import MultiPartParser as DjangoMPParser -from djangorestframework.response import ResponseException +from djangorestframework.response import ErrorResponse from djangorestframework import status from djangorestframework.utils import as_tuple from djangorestframework.mediatypes import MediaType @@ -59,7 +59,7 @@ class JSONParser(BaseParser): try: return json.load(stream) except ValueError, exc: - raise ResponseException(status.HTTP_400_BAD_REQUEST, {'detail': 'JSON parse error - %s' % str(exc)}) + raise ErrorResponse(status.HTTP_400_BAD_REQUEST, {'detail': 'JSON parse error - %s' % str(exc)}) class DataFlatener(object): diff --git a/djangorestframework/resource.py b/djangorestframework/resource.py index 1a02f8b58..f4460c1eb 100644 --- a/djangorestframework/resource.py +++ b/djangorestframework/resource.py @@ -2,9 +2,8 @@ from django.core.urlresolvers import set_script_prefix from django.views.decorators.csrf import csrf_exempt from djangorestframework.compat import View -from djangorestframework.emitters import EmitterMixin -from djangorestframework.response import Response, ResponseException -from djangorestframework.request import RequestMixin, AuthMixin +from djangorestframework.response import Response, ErrorResponse +from djangorestframework.mixins import RequestMixin, ResponseMixin, AuthMixin from djangorestframework import emitters, parsers, authenticators, validators, status @@ -16,7 +15,7 @@ from djangorestframework import emitters, parsers, authenticators, validators, s __all__ = ['Resource'] -class Resource(EmitterMixin, AuthMixin, RequestMixin, View): +class Resource(RequestMixin, ResponseMixin, AuthMixin, View): """Handles incoming requests and maps them to REST operations, performing authentication, input deserialization, input validation, output serialization.""" @@ -81,7 +80,7 @@ class Resource(EmitterMixin, AuthMixin, RequestMixin, View): def not_implemented(self, operation): """Return an HTTP 500 server error if an operation is called which has been allowed by allowed_methods, but which has not been implemented.""" - raise ResponseException(status.HTTP_500_INTERNAL_SERVER_ERROR, + raise ErrorResponse(status.HTTP_500_INTERNAL_SERVER_ERROR, {'detail': '%s operation on this resource has not been implemented' % (operation, )}) @@ -89,15 +88,15 @@ class Resource(EmitterMixin, AuthMixin, RequestMixin, View): """Ensure the request method is permitted for this resource, raising a ResourceException if it is not.""" if not method in self.callmap.keys(): - raise ResponseException(status.HTTP_501_NOT_IMPLEMENTED, + raise ErrorResponse(status.HTTP_501_NOT_IMPLEMENTED, {'detail': 'Unknown or unsupported method \'%s\'' % method}) if not method in self.allowed_methods: - raise ResponseException(status.HTTP_405_METHOD_NOT_ALLOWED, + raise ErrorResponse(status.HTTP_405_METHOD_NOT_ALLOWED, {'detail': 'Method \'%s\' not allowed on this resource.' % method}) if auth is None and not method in self.anon_allowed_methods: - raise ResponseException(status.HTTP_403_FORBIDDEN, + raise ErrorResponse(status.HTTP_403_FORBIDDEN, {'detail': 'You do not have permission to access this resource. ' + 'You may need to login or otherwise authenticate the request.'}) @@ -172,7 +171,7 @@ class Resource(EmitterMixin, AuthMixin, RequestMixin, View): # Pre-serialize filtering (eg filter complex objects into natively serializable types) response.cleaned_content = self.cleanup_response(response.raw_content) - except ResponseException, exc: + except ErrorResponse, exc: response = exc.response except: @@ -183,8 +182,12 @@ class Resource(EmitterMixin, AuthMixin, RequestMixin, View): # # TODO - this isn't actually the correct way to set the vary header, # also it's currently sub-obtimal for HTTP caching - need to sort that out. - response.headers['Allow'] = ', '.join(self.allowed_methods) - response.headers['Vary'] = 'Authenticate, Accept' + try: + response.headers['Allow'] = ', '.join(self.allowed_methods) + response.headers['Vary'] = 'Authenticate, Accept' - return self.emit(response) + return self.emit(response) + except: + import traceback + traceback.print_exc() diff --git a/djangorestframework/response.py b/djangorestframework/response.py index 809e17546..545a58343 100644 --- a/djangorestframework/response.py +++ b/djangorestframework/response.py @@ -1,24 +1,16 @@ from django.core.handlers.wsgi import STATUS_CODE_TEXT -__all__ =['NoContent', 'Response', ] - - - -class NoContent(object): - """Used to indicate no body in http response. - (We cannot just use None, as that is a valid, serializable response object.) - - TODO: On reflection I'm going to get rid of this and just not support serialized 'None' responses. - """ - pass +__all__ =['Response', 'ErrorResponse'] +# TODO: remove raw_content/cleaned_content and just use content? class Response(object): - def __init__(self, status=200, content=NoContent, headers={}): + """An HttpResponse that may include content that hasn't yet been serialized.""" + def __init__(self, status=200, content=None, headers={}): self.status = status - self.has_content_body = not content is NoContent # TODO: remove and just use content - self.raw_content = content # content prior to filtering - TODO: remove and just use content - self.cleaned_content = content # content after filtering TODO: remove and just use content + self.has_content_body = content is not None + self.raw_content = content # content prior to filtering + self.cleaned_content = content # content after filtering self.headers = headers @property @@ -28,6 +20,7 @@ class Response(object): return STATUS_CODE_TEXT.get(self.status, '') -class ResponseException(BaseException): - def __init__(self, status, content=NoContent, headers={}): +class ErrorResponse(BaseException): + """An exception representing an HttpResponse that should be returned immediatley.""" + def __init__(self, status, content=None, headers={}): self.response = Response(status, content=content, headers=headers) diff --git a/djangorestframework/tests/content.py b/djangorestframework/tests/content.py index b99f30f71..6695bf688 100644 --- a/djangorestframework/tests/content.py +++ b/djangorestframework/tests/content.py @@ -3,7 +3,7 @@ Tests for content parsing, and form-overloaded content parsing. """ from django.test import TestCase from djangorestframework.compat import RequestFactory -from djangorestframework.request import RequestMixin +from djangorestframework.mixins import RequestMixin from djangorestframework.parsers import FormParser, MultipartParser, PlainTextParser diff --git a/djangorestframework/tests/emitters.py b/djangorestframework/tests/emitters.py index 7d024ccfc..21a7eb95d 100644 --- a/djangorestframework/tests/emitters.py +++ b/djangorestframework/tests/emitters.py @@ -2,7 +2,8 @@ from django.conf.urls.defaults import patterns, url from django import http from django.test import TestCase from djangorestframework.compat import View -from djangorestframework.emitters import EmitterMixin, BaseEmitter +from djangorestframework.emitters import BaseEmitter +from djangorestframework.mixins import ResponseMixin from djangorestframework.response import Response DUMMYSTATUS = 200 @@ -11,7 +12,7 @@ DUMMYCONTENT = 'dummycontent' EMITTER_A_SERIALIZER = lambda x: 'Emitter A: %s' % x EMITTER_B_SERIALIZER = lambda x: 'Emitter B: %s' % x -class MockView(EmitterMixin, View): +class MockView(ResponseMixin, View): def get(self, request): response = Response(DUMMYSTATUS, DUMMYCONTENT) return self.emit(response) diff --git a/djangorestframework/tests/methods.py b/djangorestframework/tests/methods.py index 7f6acf4f8..0e74dc948 100644 --- a/djangorestframework/tests/methods.py +++ b/djangorestframework/tests/methods.py @@ -1,6 +1,6 @@ from django.test import TestCase from djangorestframework.compat import RequestFactory -from djangorestframework.request import RequestMixin +from djangorestframework.mixins import RequestMixin class TestMethodOverloading(TestCase): diff --git a/djangorestframework/tests/validators.py b/djangorestframework/tests/validators.py index a091cf29e..b6563db62 100644 --- a/djangorestframework/tests/validators.py +++ b/djangorestframework/tests/validators.py @@ -3,7 +3,7 @@ from django.db import models from django.test import TestCase from djangorestframework.compat import RequestFactory from djangorestframework.validators import BaseValidator, FormValidator, ModelFormValidator -from djangorestframework.response import ResponseException +from djangorestframework.response import ErrorResponse class TestValidatorMixinInterfaces(TestCase): @@ -81,7 +81,7 @@ class TestNonFieldErrors(TestCase): content = {'field1': 'example1', 'field2': 'example2'} try: FormValidator(view).validate(content) - except ResponseException, exc: + except ErrorResponse, exc: self.assertEqual(exc.response.raw_content, {'errors': [MockForm.ERROR_TEXT]}) else: self.fail('ResourceException was not raised') #pragma: no cover @@ -115,14 +115,14 @@ class TestFormValidation(TestCase): def validation_failure_raises_response_exception(self, validator): """If form validation fails a ResourceException 400 (Bad Request) should be raised.""" content = {} - self.assertRaises(ResponseException, validator.validate, content) + self.assertRaises(ErrorResponse, validator.validate, content) def validation_does_not_allow_extra_fields_by_default(self, validator): """If some (otherwise valid) content includes fields that are not in the form then validation should fail. It might be okay on normal form submission, but for Web APIs we oughta get strict, as it'll help show up broken clients more easily (eg submitting content with a misnamed field)""" content = {'qwerty': 'uiop', 'extra': 'extra'} - self.assertRaises(ResponseException, validator.validate, content) + self.assertRaises(ErrorResponse, validator.validate, content) def validation_allows_extra_fields_if_explicitly_set(self, validator): """If we include an allowed_extra_fields paramater on _validate, then allow fields with those names.""" @@ -139,7 +139,7 @@ class TestFormValidation(TestCase): content = {} try: validator.validate(content) - except ResponseException, exc: + except ErrorResponse, exc: self.assertEqual(exc.response.raw_content, {'field-errors': {'qwerty': ['This field is required.']}}) else: self.fail('ResourceException was not raised') #pragma: no cover @@ -149,7 +149,7 @@ class TestFormValidation(TestCase): content = {'qwerty': ''} try: validator.validate(content) - except ResponseException, exc: + except ErrorResponse, exc: self.assertEqual(exc.response.raw_content, {'field-errors': {'qwerty': ['This field is required.']}}) else: self.fail('ResourceException was not raised') #pragma: no cover @@ -159,7 +159,7 @@ class TestFormValidation(TestCase): content = {'qwerty': 'uiop', 'extra': 'extra'} try: validator.validate(content) - except ResponseException, exc: + except ErrorResponse, exc: self.assertEqual(exc.response.raw_content, {'field-errors': {'extra': ['This field does not exist.']}}) else: self.fail('ResourceException was not raised') #pragma: no cover @@ -169,7 +169,7 @@ class TestFormValidation(TestCase): content = {'qwerty': '', 'extra': 'extra'} try: validator.validate(content) - except ResponseException, exc: + except ErrorResponse, exc: self.assertEqual(exc.response.raw_content, {'field-errors': {'qwerty': ['This field is required.'], 'extra': ['This field does not exist.']}}) else: @@ -286,14 +286,14 @@ class TestModelFormValidator(TestCase): It might be okay on normal form submission, but for Web APIs we oughta get strict, as it'll help show up broken clients more easily (eg submitting content with a misnamed field)""" content = {'qwerty': 'example', 'uiop':'example', 'readonly': 'read only', 'extra': 'extra'} - self.assertRaises(ResponseException, self.validator.validate, content) + self.assertRaises(ErrorResponse, self.validator.validate, content) def test_validate_requires_fields_on_model_forms(self): """If some (otherwise valid) content includes fields that are not in the form then validation should fail. It might be okay on normal form submission, but for Web APIs we oughta get strict, as it'll help show up broken clients more easily (eg submitting content with a misnamed field)""" content = {'readonly': 'read only'} - self.assertRaises(ResponseException, self.validator.validate, content) + self.assertRaises(ErrorResponse, self.validator.validate, content) def test_validate_does_not_require_blankable_fields_on_model_forms(self): """Test standard ModelForm validation behaviour - fields with blank=True are not required.""" diff --git a/djangorestframework/utils.py b/djangorestframework/utils.py index d45e5acf8..8b12294c7 100644 --- a/djangorestframework/utils.py +++ b/djangorestframework/utils.py @@ -14,6 +14,7 @@ except ImportError: # """Adds the ADMIN_MEDIA_PREFIX to the request context.""" # return {'ADMIN_MEDIA_PREFIX': settings.ADMIN_MEDIA_PREFIX} +MSIE_USER_AGENT_REGEX = re.compile(r'^Mozilla/[0-9]+\.[0-9]+ \([^)]*; MSIE [0-9]+\.[0-9]+[a-z]?;[^)]*\)(?!.* Opera )') def as_tuple(obj): """Given obj return a tuple""" diff --git a/djangorestframework/validators.py b/djangorestframework/validators.py index 609e6d366..c612de551 100644 --- a/djangorestframework/validators.py +++ b/djangorestframework/validators.py @@ -1,7 +1,7 @@ """Mixin classes that provide a validate(content) function to validate and cleanup request content""" from django import forms from django.db import models -from djangorestframework.response import ResponseException +from djangorestframework.response import ErrorResponse from djangorestframework.utils import as_tuple @@ -13,7 +13,7 @@ class BaseValidator(object): def validate(self, content): """Given some content as input return some cleaned, validated content. - Typically raises a ResponseException with status code 400 (Bad Request) on failure. + Typically raises a ErrorResponse with status code 400 (Bad Request) on failure. Must be overridden to be implemented.""" raise NotImplementedError() @@ -32,11 +32,11 @@ class FormValidator(BaseValidator): def validate(self, content): """Given some content as input return some cleaned, validated content. - Raises a ResponseException with status code 400 (Bad Request) on failure. + Raises a ErrorResponse with status code 400 (Bad Request) on failure. Validation is standard form validation, with an additional constraint that no extra unknown fields may be supplied. - On failure the ResponseException content is a dict which may contain 'errors' and 'field-errors' keys. + On failure the ErrorResponse content is a dict which may contain 'errors' and 'field-errors' keys. If the 'errors' key exists it is a list of strings of non-field errors. If the 'field-errors' key exists it is a dict of {field name as string: list of errors as strings}.""" return self._validate(content) @@ -97,7 +97,7 @@ class FormValidator(BaseValidator): detail[u'field-errors'] = field_errors # Return HTTP 400 response (BAD REQUEST) - raise ResponseException(400, detail) + raise ErrorResponse(400, detail) def get_bound_form(self, content=None): @@ -139,14 +139,14 @@ class ModelFormValidator(FormValidator): # TODO: be really strict on fields - check they match in the handler methods. (this isn't a validator thing tho.) def validate(self, content): """Given some content as input return some cleaned, validated content. - Raises a ResponseException with status code 400 (Bad Request) on failure. + Raises a ErrorResponse with status code 400 (Bad Request) on failure. Validation is standard form or model form validation, with an additional constraint that no extra unknown fields may be supplied, and that all fields specified by the fields class attribute must be supplied, even if they are not validated by the form/model form. - On failure the ResponseException content is a dict which may contain 'errors' and 'field-errors' keys. + On failure the ErrorResponse content is a dict which may contain 'errors' and 'field-errors' keys. If the 'errors' key exists it is a list of strings of non-field errors. If the 'field-errors' key exists it is a dict of {field name as string: list of errors as strings}.""" return self._validate(content, allowed_extra_fields=self._property_fields_set)