feat(util,Request): Add options to disable csv parsing of query params (#820)
* Add option to to_query_str to encode lists as multiple occurences of the same parameter. * Add option to parse_query_string to not split comma-delimited values. * Add an option to RequestOptions for toggling csv parsing. In all cases, the options default to the previous behavior in order to avoid introducing any breaking changes. Co-Authored-By: Daniel Schwarz <qwesda@me.com> Co-Authored-By: Steven Ly <sly@openroad.ca> Co-Authored-By: Kurt Griffiths <inbox@kgriffs.com> Closes #749
This commit is contained in:
		@@ -284,6 +284,7 @@ class Request(object):
 | 
				
			|||||||
                self._params = parse_query_string(
 | 
					                self._params = parse_query_string(
 | 
				
			||||||
                    self.query_string,
 | 
					                    self.query_string,
 | 
				
			||||||
                    keep_blank_qs_values=self.options.keep_blank_qs_values,
 | 
					                    keep_blank_qs_values=self.options.keep_blank_qs_values,
 | 
				
			||||||
 | 
					                    parse_qs_csv=self.options.auto_parse_qs_csv,
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            else:
 | 
					            else:
 | 
				
			||||||
@@ -1153,6 +1154,7 @@ class Request(object):
 | 
				
			|||||||
            extra_params = parse_query_string(
 | 
					            extra_params = parse_query_string(
 | 
				
			||||||
                body,
 | 
					                body,
 | 
				
			||||||
                keep_blank_qs_values=self.options.keep_blank_qs_values,
 | 
					                keep_blank_qs_values=self.options.keep_blank_qs_values,
 | 
				
			||||||
 | 
					                parse_qs_csv=self.options.auto_parse_qs_csv,
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            self._params.update(extra_params)
 | 
					            self._params.update(extra_params)
 | 
				
			||||||
@@ -1190,8 +1192,11 @@ class RequestOptions(object):
 | 
				
			|||||||
    """This class is a container for ``Request`` options.
 | 
					    """This class is a container for ``Request`` options.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Attributes:
 | 
					    Attributes:
 | 
				
			||||||
        keep_blank_qs_values (bool): Set to ``True`` in order to retain
 | 
					        keep_blank_qs_values (bool): Set to ``True`` to keep query string
 | 
				
			||||||
            blank values in query string parameters (default ``False``).
 | 
					            fields even if they do not have a value (default ``False``).
 | 
				
			||||||
 | 
					            For comma-separated values, this option also determines
 | 
				
			||||||
 | 
					            whether or not empty elements in the parsed list are
 | 
				
			||||||
 | 
					            retained.
 | 
				
			||||||
        auto_parse_form_urlencoded: Set to ``True`` in order to
 | 
					        auto_parse_form_urlencoded: Set to ``True`` in order to
 | 
				
			||||||
            automatically consume the request stream and merge the
 | 
					            automatically consume the request stream and merge the
 | 
				
			||||||
            results into the request's query string params when the
 | 
					            results into the request's query string params when the
 | 
				
			||||||
@@ -1202,18 +1207,29 @@ class RequestOptions(object):
 | 
				
			|||||||
            Note:
 | 
					            Note:
 | 
				
			||||||
                The character encoding for fields, before
 | 
					                The character encoding for fields, before
 | 
				
			||||||
                percent-encoding non-ASCII bytes, is assumed to be
 | 
					                percent-encoding non-ASCII bytes, is assumed to be
 | 
				
			||||||
                UTF-8. The special `_charset_` field is ignored if present.
 | 
					                UTF-8. The special `_charset_` field is ignored if
 | 
				
			||||||
 | 
					                present.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                Falcon expects form-encoded request bodies to be
 | 
					                Falcon expects form-encoded request bodies to be
 | 
				
			||||||
                encoded according to the standard W3C algorithm (see
 | 
					                encoded according to the standard W3C algorithm (see
 | 
				
			||||||
                also http://goo.gl/6rlcux).
 | 
					                also http://goo.gl/6rlcux).
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        auto_parse_qs_csv: Set to ``False`` to treat commas in a query
 | 
				
			||||||
 | 
					            string value as literal characters, rather than as a comma-
 | 
				
			||||||
 | 
					            separated list (default ``True``). When this option is
 | 
				
			||||||
 | 
					            enabled, the value will be split on any non-percent-encoded
 | 
				
			||||||
 | 
					            commas. Disable this option when encoding lists as multiple
 | 
				
			||||||
 | 
					            occurrences of the same parameter, and when values may be
 | 
				
			||||||
 | 
					            encoded in alternative formats in which the comma character
 | 
				
			||||||
 | 
					            is significant.
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    __slots__ = (
 | 
					    __slots__ = (
 | 
				
			||||||
        'keep_blank_qs_values',
 | 
					        'keep_blank_qs_values',
 | 
				
			||||||
        'auto_parse_form_urlencoded',
 | 
					        'auto_parse_form_urlencoded',
 | 
				
			||||||
 | 
					        'auto_parse_qs_csv',
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self):
 | 
					    def __init__(self):
 | 
				
			||||||
        self.keep_blank_qs_values = False
 | 
					        self.keep_blank_qs_values = False
 | 
				
			||||||
        self.auto_parse_form_urlencoded = False
 | 
					        self.auto_parse_form_urlencoded = False
 | 
				
			||||||
 | 
					        self.auto_parse_qs_csv = True
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -148,7 +148,7 @@ def http_date_to_dt(http_date, obs_date=False):
 | 
				
			|||||||
    raise ValueError('time data %r does not match known formats' % http_date)
 | 
					    raise ValueError('time data %r does not match known formats' % http_date)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def to_query_str(params):
 | 
					def to_query_str(params, comma_delimited_lists=True):
 | 
				
			||||||
    """Converts a dictionary of params to a query string.
 | 
					    """Converts a dictionary of params to a query string.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Args:
 | 
					    Args:
 | 
				
			||||||
@@ -157,6 +157,10 @@ def to_query_str(params):
 | 
				
			|||||||
            something that can be converted into a ``str``. If `params`
 | 
					            something that can be converted into a ``str``. If `params`
 | 
				
			||||||
            is a ``list``, it will be converted to a comma-delimited string
 | 
					            is a ``list``, it will be converted to a comma-delimited string
 | 
				
			||||||
            of values (e.g., 'thing=1,2,3')
 | 
					            of values (e.g., 'thing=1,2,3')
 | 
				
			||||||
 | 
					        comma_delimited_lists (bool, default ``True``):
 | 
				
			||||||
 | 
					            If set to ``False`` encode lists by specifying multiple instances
 | 
				
			||||||
 | 
					            of the parameter (e.g., 'thing=1&thing=2&thing=3')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Returns:
 | 
					    Returns:
 | 
				
			||||||
        str: A URI query string including the '?' prefix, or an empty string
 | 
					        str: A URI query string including the '?' prefix, or an empty string
 | 
				
			||||||
@@ -175,7 +179,20 @@ def to_query_str(params):
 | 
				
			|||||||
        elif v is False:
 | 
					        elif v is False:
 | 
				
			||||||
            v = 'false'
 | 
					            v = 'false'
 | 
				
			||||||
        elif isinstance(v, list):
 | 
					        elif isinstance(v, list):
 | 
				
			||||||
            v = ','.join(map(str, v))
 | 
					            if comma_delimited_lists:
 | 
				
			||||||
 | 
					                v = ','.join(map(str, v))
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                for list_value in v:
 | 
				
			||||||
 | 
					                    if list_value is True:
 | 
				
			||||||
 | 
					                        list_value = 'true'
 | 
				
			||||||
 | 
					                    elif list_value is False:
 | 
				
			||||||
 | 
					                        list_value = 'false'
 | 
				
			||||||
 | 
					                    else:
 | 
				
			||||||
 | 
					                        list_value = str(list_value)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    query_str += k + '=' + list_value + '&'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
        else:
 | 
					        else:
 | 
				
			||||||
            v = str(v)
 | 
					            v = str(v)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -246,11 +246,12 @@ else:
 | 
				
			|||||||
        return decoded_uri.decode('utf-8', 'replace')
 | 
					        return decoded_uri.decode('utf-8', 'replace')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def parse_query_string(query_string, keep_blank_qs_values=False):
 | 
					def parse_query_string(query_string, keep_blank_qs_values=False,
 | 
				
			||||||
 | 
					                       parse_qs_csv=True):
 | 
				
			||||||
    """Parse a query string into a dict.
 | 
					    """Parse a query string into a dict.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Query string parameters are assumed to use standard form-encoding. Only
 | 
					    Query string parameters are assumed to use standard form-encoding. Only
 | 
				
			||||||
    parameters with values are parsed. for example, given 'foo=bar&flag',
 | 
					    parameters with values are returned. For example, given 'foo=bar&flag',
 | 
				
			||||||
    this function would ignore 'flag' unless the `keep_blank_qs_values` option
 | 
					    this function would ignore 'flag' unless the `keep_blank_qs_values` option
 | 
				
			||||||
    is set.
 | 
					    is set.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -269,8 +270,16 @@ def parse_query_string(query_string, keep_blank_qs_values=False):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    Args:
 | 
					    Args:
 | 
				
			||||||
        query_string (str): The query string to parse.
 | 
					        query_string (str): The query string to parse.
 | 
				
			||||||
        keep_blank_qs_values (bool): If set to ``True``, preserves boolean
 | 
					        keep_blank_qs_values (bool): Set to ``True`` to return fields even if
 | 
				
			||||||
            fields and fields with no content as blank strings.
 | 
					            they do not have a value (default ``False``). For comma-separated
 | 
				
			||||||
 | 
					            values, this option also determines whether or not empty elements
 | 
				
			||||||
 | 
					            in the parsed list are retained.
 | 
				
			||||||
 | 
					        parse_qs_csv: Set to ``False`` in order to disable splitting query
 | 
				
			||||||
 | 
					            parameters on ``,`` (default ``True``). Depending on the user agent,
 | 
				
			||||||
 | 
					            encoding lists as multiple occurrences of the same parameter might
 | 
				
			||||||
 | 
					            be preferable. In this case, setting `parse_qs_csv` to ``False``
 | 
				
			||||||
 | 
					            will cause the framework to treat commas as literal characters in
 | 
				
			||||||
 | 
					            each occurring parameter value.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Returns:
 | 
					    Returns:
 | 
				
			||||||
        dict: A dictionary of (*name*, *value*) pairs, one per query
 | 
					        dict: A dictionary of (*name*, *value*) pairs, one per query
 | 
				
			||||||
@@ -309,7 +318,7 @@ def parse_query_string(query_string, keep_blank_qs_values=False):
 | 
				
			|||||||
                params[k] = [old_value, decode(v)]
 | 
					                params[k] = [old_value, decode(v)]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        else:
 | 
					        else:
 | 
				
			||||||
            if ',' in v:
 | 
					            if parse_qs_csv and ',' in v:
 | 
				
			||||||
                # NOTE(kgriffs): Falcon supports a more compact form of
 | 
					                # NOTE(kgriffs): Falcon supports a more compact form of
 | 
				
			||||||
                # lists, in which the elements are comma-separated and
 | 
					                # lists, in which the elements are comma-separated and
 | 
				
			||||||
                # assigned to a single param instance. If it turns out that
 | 
					                # assigned to a single param instance. If it turns out that
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,16 +1,32 @@
 | 
				
			|||||||
 | 
					import ddt
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from falcon.request import RequestOptions
 | 
					from falcon.request import RequestOptions
 | 
				
			||||||
import falcon.testing as testing
 | 
					import falcon.testing as testing
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@ddt.ddt
 | 
				
			||||||
class TestRequestOptions(testing.TestBase):
 | 
					class TestRequestOptions(testing.TestBase):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_correct_options(self):
 | 
					    def test_option_defaults(self):
 | 
				
			||||||
        options = RequestOptions()
 | 
					        options = RequestOptions()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.assertFalse(options.keep_blank_qs_values)
 | 
					        self.assertFalse(options.keep_blank_qs_values)
 | 
				
			||||||
        options.keep_blank_qs_values = True
 | 
					        self.assertFalse(options.auto_parse_form_urlencoded)
 | 
				
			||||||
        self.assertTrue(options.keep_blank_qs_values)
 | 
					        self.assertTrue(options.auto_parse_qs_csv)
 | 
				
			||||||
        options.keep_blank_qs_values = False
 | 
					
 | 
				
			||||||
        self.assertFalse(options.keep_blank_qs_values)
 | 
					    @ddt.data(
 | 
				
			||||||
 | 
					        'keep_blank_qs_values',
 | 
				
			||||||
 | 
					        'auto_parse_form_urlencoded',
 | 
				
			||||||
 | 
					        'auto_parse_qs_csv',
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    def test_options_toggle(self, option_name):
 | 
				
			||||||
 | 
					        options = RequestOptions()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        setattr(options, option_name, True)
 | 
				
			||||||
 | 
					        self.assertTrue(getattr(options, option_name))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        setattr(options, option_name, False)
 | 
				
			||||||
 | 
					        self.assertFalse(getattr(options, option_name))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_incorrect_options(self):
 | 
					    def test_incorrect_options(self):
 | 
				
			||||||
        options = RequestOptions()
 | 
					        options = RequestOptions()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -65,6 +65,60 @@ class _TestQueryParams(testing.TestBase):
 | 
				
			|||||||
        self.assertEqual(req.get_param_as_list('id', int), [23, 42])
 | 
					        self.assertEqual(req.get_param_as_list('id', int), [23, 42])
 | 
				
			||||||
        self.assertEqual(req.get_param('q'), u'\u8c46 \u74e3')
 | 
					        self.assertEqual(req.get_param('q'), u'\u8c46 \u74e3')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_option_auto_parse_qs_csv_simple_false(self):
 | 
				
			||||||
 | 
					        self.api.req_options.auto_parse_qs_csv = False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        query_string = 'id=23,42,,&id=2'
 | 
				
			||||||
 | 
					        self.simulate_request('/', query_string=query_string)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        req = self.resource.req
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.assertEqual(req.params['id'], [u'23,42,,', u'2'])
 | 
				
			||||||
 | 
					        self.assertIn(req.get_param('id'), [u'23,42,,', u'2'])
 | 
				
			||||||
 | 
					        self.assertEqual(req.get_param_as_list('id'), [u'23,42,,', u'2'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_option_auto_parse_qs_csv_simple_true(self):
 | 
				
			||||||
 | 
					        self.api.req_options.auto_parse_qs_csv = True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        query_string = 'id=23,42,,&id=2'
 | 
				
			||||||
 | 
					        self.simulate_request('/', query_string=query_string)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        req = self.resource.req
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.assertEqual(req.params['id'], [u'23', u'42', u'2'])
 | 
				
			||||||
 | 
					        self.assertIn(req.get_param('id'), [u'23', u'42', u'2'])
 | 
				
			||||||
 | 
					        self.assertEqual(req.get_param_as_list('id', int), [23, 42, 2])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_option_auto_parse_qs_csv_complex_false(self):
 | 
				
			||||||
 | 
					        self.api.req_options.auto_parse_qs_csv = False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        encoded_json = '%7B%22msg%22:%22Testing%201,2,3...%22,%22code%22:857%7D'
 | 
				
			||||||
 | 
					        decoded_json = '{"msg":"Testing 1,2,3...","code":857}'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        query_string = ('colors=red,green,blue&limit=1'
 | 
				
			||||||
 | 
					                        '&list-ish1=f,,x&list-ish2=,0&list-ish3=a,,,b'
 | 
				
			||||||
 | 
					                        '&empty1=&empty2=,&empty3=,,'
 | 
				
			||||||
 | 
					                        '&thing=' + encoded_json)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.simulate_request('/', query_string=query_string)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        req = self.resource.req
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.assertIn(req.get_param('colors'), 'red,green,blue')
 | 
				
			||||||
 | 
					        self.assertEqual(req.get_param_as_list('colors'), [u'red,green,blue'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.assertEqual(req.get_param_as_list('limit'), ['1'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.assertEqual(req.get_param_as_list('empty1'), None)
 | 
				
			||||||
 | 
					        self.assertEqual(req.get_param_as_list('empty2'), [u','])
 | 
				
			||||||
 | 
					        self.assertEqual(req.get_param_as_list('empty3'), [u',,'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.assertEqual(req.get_param_as_list('list-ish1'), [u'f,,x'])
 | 
				
			||||||
 | 
					        self.assertEqual(req.get_param_as_list('list-ish2'), [u',0'])
 | 
				
			||||||
 | 
					        self.assertEqual(req.get_param_as_list('list-ish3'), [u'a,,,b'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.assertEqual(req.get_param('thing'), decoded_json)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_bad_percentage(self):
 | 
					    def test_bad_percentage(self):
 | 
				
			||||||
        query_string = 'x=%%20%+%&y=peregrine&z=%a%z%zz%1%20e'
 | 
					        query_string = 'x=%%20%+%&y=peregrine&z=%a%z%zz%1%20e'
 | 
				
			||||||
        self.simulate_request('/', query_string=query_string)
 | 
					        self.simulate_request('/', query_string=query_string)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -128,6 +128,16 @@ class TestFalconUtils(testtools.TestCase):
 | 
				
			|||||||
            falcon.to_query_str({'things': ['a', 'b']}),
 | 
					            falcon.to_query_str({'things': ['a', 'b']}),
 | 
				
			||||||
            '?things=a,b')
 | 
					            '?things=a,b')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expected = ('?things=a&things=b&things=&things=None'
 | 
				
			||||||
 | 
					                    '&things=true&things=false&things=0')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        actual = falcon.to_query_str(
 | 
				
			||||||
 | 
					            {'things': ['a', 'b', '', None, True, False, 0]},
 | 
				
			||||||
 | 
					            comma_delimited_lists=False
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.assertEqual(actual, expected)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def test_pack_query_params_several(self):
 | 
					    def test_pack_query_params_several(self):
 | 
				
			||||||
        garbage_in = {
 | 
					        garbage_in = {
 | 
				
			||||||
            'limit': 17,
 | 
					            'limit': 17,
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user