diff --git a/docs/api-guide/renderers.md b/docs/api-guide/renderers.md index b9a9fd7a3..1661ceecf 100644 --- a/docs/api-guide/renderers.md +++ b/docs/api-guide/renderers.md @@ -67,7 +67,7 @@ If your API includes views that can serve both regular webpages and API response ## JSONRenderer -Renders the request data into `JSON`. +Renders the request data into `JSON` enforcing ASCII encoding The client may additionally include an `'indent'` media type parameter, in which case the returned `JSON` will be indented. For example `Accept: application/json; indent=4`. @@ -75,6 +75,10 @@ The client may additionally include an `'indent'` media type parameter, in which **.format**: `'.json'` +## UnicodeJSONRenderer + +Same as `JSONRenderer` but doesn't enforce ASCII encoding + ## JSONPRenderer Renders the request data into `JSONP`. The `JSONP` media type provides a mechanism of allowing cross-domain AJAX requests, by wrapping a `JSON` response in a javascript callback. diff --git a/docs/topics/credits.md b/docs/topics/credits.md index d805c0c1d..acd6576ec 100644 --- a/docs/topics/credits.md +++ b/docs/topics/credits.md @@ -130,6 +130,8 @@ The following people have helped make REST framework great. * Òscar Vilaplana - [grimborg] * Ryan Kaskel - [ryankask] * Andy McKay - [andymckay] +* Matteo Suppo - [matteosuppo] +* Karol Majta - [lolek09] Many thanks to everyone who's contributed to the project. @@ -296,3 +298,5 @@ You can also contact [@_tomchristie][twitter] directly on twitter. [grimborg]: https://github.com/grimborg [ryankask]: https://github.com/ryankask [andymckay]: https://github.com/andymckay +[matteosuppo]: https://github.com/matteosuppo +[lolek09]: https://github.com/lolek09 diff --git a/rest_framework/compat.py b/rest_framework/compat.py index cd39f5445..76dc00526 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -495,3 +495,16 @@ except ImportError: oauth2_provider_forms = None oauth2_provider_scope = None oauth2_constants = None + +# Handle lazy strings +from django.utils.functional import Promise + +if six.PY3: + def is_non_str_iterable(obj): + if (isinstance(obj, str) or + (isinstance(obj, Promise) and obj._delegate_text)): + return False + return hasattr(obj, '__iter__') +else: + def is_non_str_iterable(obj): + return hasattr(obj, '__iter__') diff --git a/rest_framework/fields.py b/rest_framework/fields.py index fc14184cc..b5f99823a 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -26,7 +26,7 @@ from rest_framework import ISO_8601 from rest_framework.compat import timezone, parse_date, parse_datetime, parse_time from rest_framework.compat import BytesIO from rest_framework.compat import six -from rest_framework.compat import smart_text +from rest_framework.compat import smart_text, force_text, is_non_str_iterable from rest_framework.settings import api_settings @@ -45,7 +45,6 @@ def is_simple_callable(obj): len_defaults = len(defaults) if defaults else 0 return len_args <= len_defaults - def get_component(obj, attr_name): """ Given an object, and an attribute name, @@ -169,7 +168,8 @@ class Field(object): if is_protected_type(value): return value - elif hasattr(value, '__iter__') and not isinstance(value, (dict, six.string_types)): + elif (is_non_str_iterable(value) and + not isinstance(value, (dict, six.string_types))): return [self.to_native(item) for item in value] elif isinstance(value, dict): # Make sure we preserve field ordering, if it exists @@ -177,7 +177,7 @@ class Field(object): for key, val in value.items(): ret[key] = self.to_native(val) return ret - return smart_text(value) + return force_text(value) def attributes(self): """ diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index 8361cd409..c67c8ed6f 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -36,6 +36,7 @@ class BaseRenderer(object): media_type = None format = None + charset = None def render(self, data, accepted_media_type=None, renderer_context=None): raise NotImplemented('Renderer class requires .render() to be implemented') @@ -49,6 +50,7 @@ class JSONRenderer(BaseRenderer): media_type = 'application/json' format = 'json' encoder_class = encoders.JSONEncoder + ensure_ascii = True def render(self, data, accepted_media_type=None, renderer_context=None): """ @@ -72,7 +74,12 @@ class JSONRenderer(BaseRenderer): except (ValueError, TypeError): indent = None - return json.dumps(data, cls=self.encoder_class, indent=indent) + return json.dumps(data, cls=self.encoder_class, indent=indent, ensure_ascii=self.ensure_ascii) + + +class UnicodeJSONRenderer(JSONRenderer): + ensure_ascii = False + charset = 'utf-8' class JSONPRenderer(JSONRenderer): @@ -115,6 +122,7 @@ class XMLRenderer(BaseRenderer): media_type = 'application/xml' format = 'xml' + charset = 'utf-8' def render(self, data, accepted_media_type=None, renderer_context=None): """ @@ -164,6 +172,7 @@ class YAMLRenderer(BaseRenderer): media_type = 'application/yaml' format = 'yaml' encoder = encoders.SafeDumper + charset = 'utf-8' def render(self, data, accepted_media_type=None, renderer_context=None): """ @@ -204,6 +213,7 @@ class TemplateHTMLRenderer(BaseRenderer): '%(status_code)s.html', 'api_exception.html' ] + charset = 'utf-8' def render(self, data, accepted_media_type=None, renderer_context=None): """ @@ -275,6 +285,7 @@ class StaticHTMLRenderer(TemplateHTMLRenderer): """ media_type = 'text/html' format = 'html' + charset = 'utf-8' def render(self, data, accepted_media_type=None, renderer_context=None): renderer_context = renderer_context or {} @@ -296,6 +307,7 @@ class BrowsableAPIRenderer(BaseRenderer): media_type = 'text/html' format = 'api' template = 'rest_framework/api.html' + charset = 'utf-8' def get_default_renderer(self, view): """ @@ -321,7 +333,7 @@ class BrowsableAPIRenderer(BaseRenderer): content = renderer.render(data, accepted_media_type, renderer_context) if not all(char in string.printable for char in content): - return '[%d bytes of binary content]' + return '[%d bytes of binary content]' % len(content) return content @@ -337,6 +349,8 @@ class BrowsableAPIRenderer(BaseRenderer): try: view.check_permissions(request) + if obj is not None: + view.check_object_permissions(request, obj) except exceptions.APIException: return False # Doesn't have permissions return True diff --git a/rest_framework/response.py b/rest_framework/response.py index 26e4ab371..32e74a45e 100644 --- a/rest_framework/response.py +++ b/rest_framework/response.py @@ -18,7 +18,7 @@ class Response(SimpleTemplateResponse): def __init__(self, data=None, status=200, template_name=None, headers=None, - exception=False): + exception=False, charset=None): """ Alters the init arguments slightly. For example, drop 'template_name', and instead use 'data'. @@ -30,6 +30,7 @@ class Response(SimpleTemplateResponse): self.data = data self.template_name = template_name self.exception = exception + self.charset = charset if headers: for name, value in six.iteritems(headers): @@ -46,7 +47,14 @@ class Response(SimpleTemplateResponse): assert context, ".renderer_context not set on Response" context['response'] = self - self['Content-Type'] = media_type + if self.charset is None: + self.charset = renderer.charset + + if self.charset is not None: + content_type = "{0}; charset={1}".format(media_type, self.charset) + else: + content_type = media_type + self['Content-Type'] = content_type return renderer.render(self.data, media_type, context) @property diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index ff5eb8732..943fba6ba 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -744,6 +744,11 @@ class ModelSerializer(Serializer): kwargs['choices'] = model_field.flatchoices return ChoiceField(**kwargs) + # put this below the ChoiceField because min_value isn't a valid initializer + if issubclass(model_field.__class__, models.PositiveIntegerField) or\ + issubclass(model_field.__class__, models.PositiveSmallIntegerField): + kwargs['min_value'] = 0 + attribute_dict = { models.CharField: ['max_length'], models.CommaSeparatedIntegerField: ['max_length'], diff --git a/rest_framework/tests/test_htmlrenderer.py b/rest_framework/tests/test_htmlrenderer.py index 96804e1dd..8957a43c7 100644 --- a/rest_framework/tests/test_htmlrenderer.py +++ b/rest_framework/tests/test_htmlrenderer.py @@ -66,19 +66,19 @@ class TemplateHTMLRendererTests(TestCase): def test_simple_html_view(self): response = self.client.get('/') self.assertContains(response, "example: foobar") - self.assertEqual(response['Content-Type'], 'text/html') + self.assertEqual(response['Content-Type'], 'text/html; charset=utf-8') def test_not_found_html_view(self): response = self.client.get('/not_found') self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(response.content, six.b("404 Not Found")) - self.assertEqual(response['Content-Type'], 'text/html') + self.assertEqual(response['Content-Type'], 'text/html; charset=utf-8') def test_permission_denied_html_view(self): response = self.client.get('/permission_denied') self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.content, six.b("403 Forbidden")) - self.assertEqual(response['Content-Type'], 'text/html') + self.assertEqual(response['Content-Type'], 'text/html; charset=utf-8') class TemplateHTMLRendererExceptionTests(TestCase): @@ -109,10 +109,10 @@ class TemplateHTMLRendererExceptionTests(TestCase): response = self.client.get('/not_found') self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(response.content, six.b("404: Not found")) - self.assertEqual(response['Content-Type'], 'text/html') + self.assertEqual(response['Content-Type'], 'text/html; charset=utf-8') def test_permission_denied_html_view_with_template(self): response = self.client.get('/permission_denied') self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.content, six.b("403: Permission denied")) - self.assertEqual(response['Content-Type'], 'text/html') + self.assertEqual(response['Content-Type'], 'text/html; charset=utf-8') diff --git a/rest_framework/tests/test_negotiation.py b/rest_framework/tests/test_negotiation.py index 43721b842..7f84827f0 100644 --- a/rest_framework/tests/test_negotiation.py +++ b/rest_framework/tests/test_negotiation.py @@ -3,19 +3,24 @@ from django.test import TestCase from django.test.client import RequestFactory from rest_framework.negotiation import DefaultContentNegotiation from rest_framework.request import Request +from rest_framework.renderers import BaseRenderer factory = RequestFactory() -class MockJSONRenderer(object): +class MockJSONRenderer(BaseRenderer): media_type = 'application/json' -class MockHTMLRenderer(object): +class MockHTMLRenderer(BaseRenderer): media_type = 'text/html' +class NoCharsetSpecifiedRenderer(BaseRenderer): + media_type = 'my/media' + + class TestAcceptedMediaType(TestCase): def setUp(self): self.renderers = [MockJSONRenderer(), MockHTMLRenderer()] diff --git a/rest_framework/tests/test_renderers.py b/rest_framework/tests/test_renderers.py index 2a2c72575..1584fc9f7 100644 --- a/rest_framework/tests/test_renderers.py +++ b/rest_framework/tests/test_renderers.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- from decimal import Decimal from django.core.cache import cache from django.test import TestCase @@ -8,7 +9,7 @@ from rest_framework.compat import yaml, etree, patterns, url, include from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.renderers import BaseRenderer, JSONRenderer, YAMLRenderer, \ - XMLRenderer, JSONPRenderer, BrowsableAPIRenderer + XMLRenderer, JSONPRenderer, BrowsableAPIRenderer, UnicodeJSONRenderer from rest_framework.parsers import YAMLParser, XMLParser from rest_framework.settings import api_settings from rest_framework.compat import StringIO @@ -254,6 +255,23 @@ class JSONRendererTests(TestCase): content = renderer.render(obj, 'application/json; indent=2') self.assertEqual(strip_trailing_whitespace(content), _indented_repr) + def test_check_ascii(self): + obj = {'countries': ['United Kingdom', 'France', 'España']} + renderer = JSONRenderer() + content = renderer.render(obj, 'application/json') + self.assertEqual(content, '{"countries": ["United Kingdom", "France", "Espa\\u00f1a"]}') + + +class UnicodeJSONRendererTests(TestCase): + """ + Tests specific for the Unicode JSON Renderer + """ + def test_proper_encoding(self): + obj = {'countries': ['United Kingdom', 'France', 'España']} + renderer = UnicodeJSONRenderer() + content = renderer.render(obj, 'application/json') + self.assertEqual(content, '{"countries": ["United Kingdom", "France", "España"]}') + class JSONPRendererTests(TestCase): """ diff --git a/rest_framework/tests/test_response.py b/rest_framework/tests/test_response.py index cea2534c7..9501f9cb7 100644 --- a/rest_framework/tests/test_response.py +++ b/rest_framework/tests/test_response.py @@ -21,6 +21,9 @@ class MockJsonRenderer(BaseRenderer): media_type = 'application/json' +class MockTextMediaRenderer(BaseRenderer): + media_type = 'text/html' + DUMMYSTATUS = status.HTTP_200_OK DUMMYCONTENT = 'dummycontent' @@ -44,13 +47,26 @@ class RendererB(BaseRenderer): return RENDERER_B_SERIALIZER(data) +class RendererC(RendererB): + media_type = 'mock/rendererc' + format = 'formatc' + charset = "rendererc" + + class MockView(APIView): - renderer_classes = (RendererA, RendererB) + renderer_classes = (RendererA, RendererB, RendererC) def get(self, request, **kwargs): return Response(DUMMYCONTENT, status=DUMMYSTATUS) +class MockViewSettingCharset(APIView): + renderer_classes = (RendererA, RendererB, RendererC) + + def get(self, request, **kwargs): + return Response(DUMMYCONTENT, status=DUMMYSTATUS, charset='setbyview') + + class HTMLView(APIView): renderer_classes = (BrowsableAPIRenderer, ) @@ -64,10 +80,10 @@ class HTMLView1(APIView): def get(self, request, **kwargs): return Response('text') - urlpatterns = patterns('', - url(r'^.*\.(?P.+)$', MockView.as_view(renderer_classes=[RendererA, RendererB])), - url(r'^$', MockView.as_view(renderer_classes=[RendererA, RendererB])), + url(r'^setbyview$', MockViewSettingCharset.as_view(renderer_classes=[RendererA, RendererB, RendererC])), + url(r'^.*\.(?P.+)$', MockView.as_view(renderer_classes=[RendererA, RendererB, RendererC])), + url(r'^$', MockView.as_view(renderer_classes=[RendererA, RendererB, RendererC])), url(r'^html$', HTMLView.as_view()), url(r'^html1$', HTMLView1.as_view()), url(r'^restframework', include('rest_framework.urls', namespace='rest_framework')) @@ -173,3 +189,38 @@ class Issue122Tests(TestCase): Test if no infinite recursion occurs. """ self.client.get('/html1') + + +class Issue807Testts(TestCase): + """ + Covers #807 + """ + + urls = 'rest_framework.tests.response' + + def test_does_not_append_charset_by_default(self): + """ + Renderers don't include a charset unless set explicitly. + """ + headers = {"HTTP_ACCEPT": RendererA.media_type} + resp = self.client.get('/', **headers) + self.assertEqual(RendererA.media_type, resp['Content-Type']) + + def test_if_there_is_charset_specified_on_renderer_it_gets_appended(self): + """ + If renderer class has charset attribute declared, it gets appended + to Response's Content-Type + """ + headers = {"HTTP_ACCEPT": RendererC.media_type} + resp = self.client.get('/', **headers) + expected = "{0}; charset={1}".format(RendererC.media_type, RendererC.charset) + self.assertEqual(expected, resp['Content-Type']) + + def test_charset_set_explictly_on_response(self): + """ + The charset may be set explictly on the response. + """ + headers = {"HTTP_ACCEPT": RendererC.media_type} + resp = self.client.get('/setbyview', **headers) + expected = "{0}; charset={1}".format(RendererC.media_type, 'setbyview') + self.assertEqual(expected, resp['Content-Type']) diff --git a/rest_framework/tests/test_serializer.py b/rest_framework/tests/test_serializer.py index 30162e423..f0751a748 100644 --- a/rest_framework/tests/test_serializer.py +++ b/rest_framework/tests/test_serializer.py @@ -1,8 +1,9 @@ from __future__ import unicode_literals from django.db import models from django.db.models.fields import BLANK_CHOICE_DASH -from django.utils.datastructures import MultiValueDict from django.test import TestCase +from django.utils.datastructures import MultiValueDict +from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers from rest_framework.tests.models import (HasPositiveIntegerAsChoice, Album, ActionItem, Anchor, BasicModel, BlankFieldModel, BlogPost, BlogPostComment, Book, CallableDefaultValueModel, DefaultValueModel, @@ -1323,6 +1324,34 @@ class DeserializeListTestCase(TestCase): self.assertEqual(serializer.errors, expected) +# test for issue 747 + + +class LazyStringModel(object): + def __init__(self, lazystring): + self.lazystring = lazystring + + +class LazyStringSerializer(serializers.Serializer): + lazystring = serializers.Field() + + def restore_object(self, attrs, instance=None): + if instance is not None: + instance.lazystring = attrs.get('lazystring', instance.lazystring) + return instance + return LazyStringModel(**attrs) + + +class LazyStringsTestCase(TestCase): + def setUp(self): + self.model = LazyStringModel(lazystring=_('lazystring')) + + def test_lazy_strings_are_translated(self): + serializer = LazyStringSerializer(self.model) + self.assertEqual(type(serializer.data['lazystring']), + type('lazystring')) + + class AttributeMappingOnAutogeneratedFieldsTests(TestCase): def setUp(self): @@ -1402,3 +1431,77 @@ class AttributeMappingOnAutogeneratedFieldsTests(TestCase): def test_url_field(self): self.check_field('url_field') + + +class DefaultValuesOnAutogeneratedFieldsTests(TestCase): + + def setUp(self): + class DVOAFModel(RESTFrameworkModel): + positive_integer_field = models.PositiveIntegerField(blank=True) + positive_small_integer_field = models.PositiveSmallIntegerField(blank=True) + email_field = models.EmailField(blank=True) + file_field = models.FileField(blank=True) + image_field = models.ImageField(blank=True) + slug_field = models.SlugField(blank=True) + url_field = models.URLField(blank=True) + + class DVOAFSerializer(serializers.ModelSerializer): + class Meta: + model = DVOAFModel + + self.serializer_class = DVOAFSerializer + self.fields_attributes = { + 'positive_integer_field': [ + ('min_value', 0), + ], + 'positive_small_integer_field': [ + ('min_value', 0), + ], + 'email_field': [ + ('max_length', 75), + ], + 'file_field': [ + ('max_length', 100), + ], + 'image_field': [ + ('max_length', 100), + ], + 'slug_field': [ + ('max_length', 50), + ], + 'url_field': [ + ('max_length', 200), + ], + } + + def check_field(self, field): + serializer = self.serializer_class(data={}) + self.assertEqual(serializer.is_valid(), True) + + for attribute in self.fields_attributes[field]: + self.assertEqual( + getattr(serializer.fields[field], attribute[0]), + attribute[1] + ) + + def test_positive_integer_field(self): + self.check_field('positive_integer_field') + + def test_positive_small_integer_field(self): + self.check_field('positive_small_integer_field') + + def test_email_field(self): + self.check_field('email_field') + + def test_file_field(self): + self.check_field('file_field') + + def test_image_field(self): + self.check_field('image_field') + + def test_slug_field(self): + self.check_field('slug_field') + + def test_url_field(self): + self.check_field('url_field') +