Skip to content

Use strict float handling in JSON functions #5265

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Sep 25, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/api-guide/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,14 @@ The default style is to return minified responses, in line with [Heroku's API de

Default: `True`

#### STRICT_JSON

When set to `True`, JSON rendering and parsing will only observe syntactically valid JSON, raising an exception for the extended float values (`nan`, `inf`, `-inf`) accepted by Python's `json` module. This is the recommended setting, as these values are not generally supported. e.g., neither Javascript's `JSON.Parse` nor PostgreSQL's JSON data type accept these values.

When set to `False`, JSON rendering and parsing will be permissive. However, these values are still invalid and will need to be specially handled in your code.

Default: `True`

#### COERCE_DECIMAL_TO_STRING

When returning decimal objects in API representations that do not support a native decimal type, it is normally best to return the value as a string. This avoids the loss of precision that occurs with binary floating point implementations.
Expand Down
1 change: 1 addition & 0 deletions requirements/requirements-codestyle.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# PEP8 code linting, which we run on all commits.
flake8==2.4.0
flake8-tidy-imports==1.1.0
pep8==1.5.7

# Sort and lint imports
Expand Down
3 changes: 1 addition & 2 deletions rest_framework/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import datetime
import decimal
import inspect
import json
import re
import uuid
from collections import OrderedDict
Expand Down Expand Up @@ -38,7 +37,7 @@
)
from rest_framework.exceptions import ErrorDetail, ValidationError
from rest_framework.settings import api_settings
from rest_framework.utils import html, humanize_datetime, representation
from rest_framework.utils import html, humanize_datetime, json, representation


class empty:
Expand Down
7 changes: 5 additions & 2 deletions rest_framework/parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from __future__ import unicode_literals

import codecs
import json

from django.conf import settings
from django.core.files.uploadhandler import StopFutureHandlers
Expand All @@ -23,6 +22,8 @@

from rest_framework import renderers
from rest_framework.exceptions import ParseError
from rest_framework.settings import api_settings
from rest_framework.utils import json


class DataAndFiles(object):
Expand Down Expand Up @@ -53,6 +54,7 @@ class JSONParser(BaseParser):
"""
media_type = 'application/json'
renderer_class = renderers.JSONRenderer
strict = api_settings.STRICT_JSON

def parse(self, stream, media_type=None, parser_context=None):
"""
Expand All @@ -63,7 +65,8 @@ def parse(self, stream, media_type=None, parser_context=None):

try:
decoded_stream = codecs.getreader(encoding)(stream)
return json.load(decoded_stream)
parse_constant = json.strict_constant if self.strict else None
return json.load(decoded_stream, parse_constant=parse_constant)
except ValueError as exc:
raise ParseError('JSON parse error - %s' % six.text_type(exc))

Expand Down
6 changes: 3 additions & 3 deletions rest_framework/renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
from __future__ import unicode_literals

import base64
import json
from collections import OrderedDict

from django import forms
Expand All @@ -30,7 +29,7 @@
from rest_framework.exceptions import ParseError
from rest_framework.request import is_form_media_type, override_method
from rest_framework.settings import api_settings
from rest_framework.utils import encoders
from rest_framework.utils import encoders, json
from rest_framework.utils.breadcrumbs import get_breadcrumbs
from rest_framework.utils.field_mapping import ClassLookupDict

Expand Down Expand Up @@ -62,6 +61,7 @@ class JSONRenderer(BaseRenderer):
encoder_class = encoders.JSONEncoder
ensure_ascii = not api_settings.UNICODE_JSON
compact = api_settings.COMPACT_JSON
strict = api_settings.STRICT_JSON

# We don't set a charset because JSON is a binary encoding,
# that can be encoded as utf-8, utf-16 or utf-32.
Expand Down Expand Up @@ -102,7 +102,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None):
ret = json.dumps(
data, cls=self.encoder_class,
indent=indent, ensure_ascii=self.ensure_ascii,
separators=separators
allow_nan=not self.strict, separators=separators
)

# On python 2.x json.dumps() returns bytestrings if ensure_ascii=True,
Expand Down
1 change: 1 addition & 0 deletions rest_framework/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
# Encoding
'UNICODE_JSON': True,
'COMPACT_JSON': True,
'STRICT_JSON': True,
'COERCE_DECIMAL_TO_STRING': True,
'UPLOADED_FILES_USE_URL': True,

Expand Down
4 changes: 2 additions & 2 deletions rest_framework/utils/encoders.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"""
Helper classes for parsers.
"""
from __future__ import unicode_literals
from __future__ import absolute_import, unicode_literals

import datetime
import decimal
import json
import json # noqa
import uuid

from django.db.models.query import QuerySet
Expand Down
40 changes: 40 additions & 0 deletions rest_framework/utils/json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""
Wrapper for the builtin json module that ensures compliance with the JSON spec.

REST framework should always import this wrapper module in order to maintain
spec-compliant encoding/decoding. Support for non-standard features should be
handled by users at the renderer and parser layer.
"""

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth a docstring here to highlight why we have this module.

from __future__ import absolute_import

import functools
import json # noqa


def strict_constant(o):
raise ValueError('Out of range float values are not JSON compliant: ' + repr(o))


@functools.wraps(json.dump)
def dump(*args, **kwargs):
kwargs.setdefault('allow_nan', False)
return json.dump(*args, **kwargs)


@functools.wraps(json.dumps)
def dumps(*args, **kwargs):
kwargs.setdefault('allow_nan', False)
return json.dumps(*args, **kwargs)


@functools.wraps(json.load)
def load(*args, **kwargs):
kwargs.setdefault('parse_constant', strict_constant)
return json.load(*args, **kwargs)


@functools.wraps(json.loads)
def loads(*args, **kwargs):
kwargs.setdefault('parse_constant', strict_constant)
return json.loads(*args, **kwargs)
4 changes: 2 additions & 2 deletions rest_framework/utils/serializer_helpers.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
from __future__ import unicode_literals

import collections
import json
from collections import OrderedDict

from django.utils.encoding import force_text

from rest_framework.compat import unicode_to_repr
from rest_framework.utils import json


class ReturnDict(OrderedDict):
Expand Down Expand Up @@ -88,7 +88,7 @@ def as_form_field(self):
value = self.value
try:
value = json.dumps(self.value, sort_keys=True, indent=4)
except TypeError:
except (TypeError, ValueError):
pass
return self.__class__(self._field, value, self.errors, self._prefix)

Expand Down
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ license_file = LICENSE.md

[flake8]
ignore = E501
banned-modules = json = use from rest_framework.utils import json!
1 change: 1 addition & 0 deletions tests/test_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -1811,6 +1811,7 @@ class TestJSONField(FieldValues):
]
invalid_inputs = [
({'a': set()}, ['Value must be valid JSON.']),
({'a': float('inf')}, ['Value must be valid JSON.']),
]
outputs = [
({
Expand Down
24 changes: 21 additions & 3 deletions tests/test_parsers.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
# -*- coding: utf-8 -*-

from __future__ import unicode_literals

import math

import pytest
from django import forms
from django.core.files.uploadhandler import (
MemoryFileUploadHandler, TemporaryFileUploadHandler
)
from django.http.request import RawPostDataException
from django.test import TestCase
from django.utils.six.moves import StringIO
from django.utils.six import BytesIO, StringIO

from rest_framework.exceptions import ParseError
from rest_framework.parsers import (
Expand Down Expand Up @@ -42,7 +43,6 @@ class TestFileUploadParser(TestCase):
def setUp(self):
class MockRequest(object):
pass
from io import BytesIO
self.stream = BytesIO(
"Test text file".encode('utf-8')
)
Expand Down Expand Up @@ -129,6 +129,24 @@ def __replace_content_disposition(self, disposition):
self.parser_context['request'].META['HTTP_CONTENT_DISPOSITION'] = disposition


class TestJSONParser(TestCase):
def bytes(self, value):
return BytesIO(value.encode('utf-8'))

def test_float_strictness(self):
parser = JSONParser()

# Default to strict
for value in ['Infinity', '-Infinity', 'NaN']:
with pytest.raises(ParseError):
parser.parse(self.bytes(value))

parser.strict = False
assert parser.parse(self.bytes('Infinity')) == float('inf')
assert parser.parse(self.bytes('-Infinity')) == float('-inf')
assert math.isnan(parser.parse(self.bytes('NaN')))


class TestPOSTAccessed(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
Expand Down
15 changes: 14 additions & 1 deletion tests/test_renderers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals

import json
import re
from collections import MutableMapping, OrderedDict

Expand All @@ -24,6 +23,7 @@
from rest_framework.response import Response
from rest_framework.settings import api_settings
from rest_framework.test import APIRequestFactory
from rest_framework.utils import json
from rest_framework.views import APIView

DUMMYSTATUS = status.HTTP_200_OK
Expand Down Expand Up @@ -357,6 +357,19 @@ def __getitem__(self, key):
with self.assertRaises(TypeError):
JSONRenderer().render(x)

def test_float_strictness(self):
renderer = JSONRenderer()

# Default to strict
for value in [float('inf'), float('-inf'), float('nan')]:
with pytest.raises(ValueError):
renderer.render(value)

renderer.strict = False
assert renderer.render(float('inf')) == b'Infinity'
assert renderer.render(float('-inf')) == b'-Infinity'
assert renderer.render(float('nan')) == b'NaN'

def test_without_content_type_args(self):
"""
Test basic JSON rendering.
Expand Down
2 changes: 1 addition & 1 deletion tests/test_routers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import unicode_literals

import json
from collections import namedtuple

import pytest
Expand All @@ -15,6 +14,7 @@
from rest_framework.response import Response
from rest_framework.routers import DefaultRouter, SimpleRouter
from rest_framework.test import APIRequestFactory
from rest_framework.utils import json

factory = APIRequestFactory()

Expand Down
28 changes: 28 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from rest_framework.compat import _resolve_model
from rest_framework.routers import SimpleRouter
from rest_framework.serializers import ModelSerializer
from rest_framework.utils import json
from rest_framework.utils.breadcrumbs import get_breadcrumbs
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
Expand Down Expand Up @@ -177,3 +178,30 @@ def tearDown(self):
def test_blows_up_if_model_does_not_resolve(self):
with self.assertRaises(ImproperlyConfigured):
_resolve_model('tests.BasicModel')


class JsonFloatTests(TestCase):
"""
Internaly, wrapped json functions should adhere to strict float handling
"""

def test_dumps(self):
with self.assertRaises(ValueError):
json.dumps(float('inf'))

with self.assertRaises(ValueError):
json.dumps(float('nan'))

def test_loads(self):
with self.assertRaises(ValueError):
json.loads("Infinity")

with self.assertRaises(ValueError):
json.loads("NaN")


@override_settings(STRICT_JSON=False)
class NonStrictJsonFloatTests(JsonFloatTests):
"""
'STRICT_JSON = False' should not somehow affect internal json behavior
"""