diff --git a/docs/sanic/request_data.md b/docs/sanic/request_data.md index 01a182a3..065d8e13 100644 --- a/docs/sanic/request_data.md +++ b/docs/sanic/request_data.md @@ -19,6 +19,8 @@ The following variables are accessible as properties on `Request` objects: URL that resembles `?key1=value1&key2=value2`. If that URL were to be parsed, the `args` dictionary would look like `{'key1': ['value1'], 'key2': ['value2']}`. The request's `query_string` variable holds the unparsed string value. + Property is providing the default parsing strategy. If you would like to change it look to the section below + (`Changing the default parsing rules of the queryset`). ```python from sanic.response import json @@ -28,9 +30,54 @@ The following variables are accessible as properties on `Request` objects: return json({ "parsed": True, "args": request.args, "url": request.url, "query_string": request.query_string }) ``` -- `raw_args` (dict) - On many cases you would need to access the url arguments in - a less packed dictionary. For same previous URL `?key1=value1&key2=value2`, the - `raw_args` dictionary would look like `{'key1': 'value1', 'key2': 'value2'}`. +- `query_args` (list) - On many cases you would need to access the url arguments in + a less packed form. `query_args` is the list of `(key, value)` tuples. + Property is providing the default parsing strategy. If you would like to change it look to the section below + (`Changing the default parsing rules of the queryset`). + For the same previous URL queryset `?key1=value1&key2=value2`, the + `query_args` list would look like `[('key1', 'value1'), ('key2', 'value2')]`. + And in case of the multiple params with the same key like `?key1=value1&key2=value2&key1=value3` + the `query_args` list would look like `[('key1', 'value1'), ('key2', 'value2'), ('key1', 'value3')]`. + + The difference between Request.args and Request.query_args + for the queryset `?key1=value1&key2=value2&key1=value3` + + ```python + from sanic import Sanic + from sanic.response import json + + app = Sanic(__name__) + + + @app.route("/test_request_args") + async def test_request_args(request): + return json({ + "parsed": True, + "url": request.url, + "query_string": request.query_string, + "args": request.args, + "raw_args": request.raw_args, + "query_args": request.query_args, + }) + + if __name__ == '__main__': + app.run(host="0.0.0.0", port=8000) + ``` + + Output + + ``` + { + "parsed":true, + "url":"http:\/\/0.0.0.0:8000\/test_request_args?key1=value1&key2=value2&key1=value3", + "query_string":"key1=value1&key2=value2&key1=value3", + "args":{"key1":["value1","value3"],"key2":["value2"]}, + "raw_args":{"key1":"value1","key2":"value2"}, + "query_args":[["key1","value1"],["key2","value2"],["key1","value3"]] + } + ``` + + `raw_args` contains only the first entry of `key1`. Will be deprecated in the future versions. - `files` (dictionary of `File` objects) - List of files that have a name, body, and type @@ -106,6 +153,51 @@ The following variables are accessible as properties on `Request` objects: - `token`: The value of Authorization header: `Basic YWRtaW46YWRtaW4=` +## Changing the default parsing rules of the queryset + +The default parameters that are using internally in `args` and `query_args` properties to parse queryset: + +- `keep_blank_values` (bool): `False` - flag indicating whether blank values in + percent-encoded queries should be treated as blank strings. + A true value indicates that blanks should be retained as blank + strings. The default false value indicates that blank values + are to be ignored and treated as if they were not included. +- `strict_parsing` (bool): `False` - flag indicating what to do with parsing errors. If + false (the default), errors are silently ignored. If true, + errors raise a ValueError exception. +- `encoding` and `errors` (str): 'utf-8' and 'replace' - specify how to decode percent-encoded sequences + into Unicode characters, as accepted by the bytes.decode() method. + +If you would like to change that default parameters you could call `get_args` and `get_query_args` methods +with the new values. + +For the queryset `/?test1=value1&test2=&test3=value3`: + +```python +from sanic.response import json + +@app.route("/query_string") +def query_string(request): + args_with_blank_values = request.get_args(keep_blank_values=True) + return json({ + "parsed": True, + "url": request.url, + "args_with_blank_values": args_with_blank_values, + "query_string": request.query_string + }) +``` + +The output will be: + +``` +{ + "parsed": true, + "url": "http:\/\/0.0.0.0:8000\/query_string?test1=value1&test2=&test3=value3", + "args_with_blank_values": {"test1": ["value1""], "test2": "", "test3": ["value3"]}, + "query_string": "test1=value1&test2=&test3=value3" +} +``` + ## Accessing values using `get` and `getlist` The request properties which return a dictionary actually return a subclass of diff --git a/sanic/request.py b/sanic/request.py index 90c9de19..76975801 100644 --- a/sanic/request.py +++ b/sanic/request.py @@ -2,11 +2,12 @@ import asyncio import email.utils import json import sys +import warnings from cgi import parse_header -from collections import namedtuple +from collections import defaultdict, namedtuple from http.cookies import SimpleCookie -from urllib.parse import parse_qs, unquote, urlunparse +from urllib.parse import parse_qs, parse_qsl, unquote, urlunparse from httptools import parse_url @@ -83,6 +84,7 @@ class Request(dict): "headers", "method", "parsed_args", + "parsed_not_grouped_args", "parsed_files", "parsed_form", "parsed_json", @@ -109,7 +111,8 @@ class Request(dict): self.parsed_json = None self.parsed_form = None self.parsed_files = None - self.parsed_args = None + self.parsed_args = defaultdict(RequestParameters) + self.parsed_not_grouped_args = defaultdict(list) self.uri_template = None self._cookies = None self.stream = None @@ -199,21 +202,117 @@ class Request(dict): return self.parsed_files - @property - def args(self): - if self.parsed_args is None: + def get_args( + self, + keep_blank_values: bool = False, + strict_parsing: bool = False, + encoding: str = "utf-8", + errors: str = "replace", + ) -> RequestParameters: + """ + Method to parse `query_string` using `urllib.parse.parse_qs`. + This methods is used by `args` property. + Can be used directly if you need to change default parameters. + :param keep_blank_values: flag indicating whether blank values in + percent-encoded queries should be treated as blank strings. + A true value indicates that blanks should be retained as blank + strings. The default false value indicates that blank values + are to be ignored and treated as if they were not included. + :type keep_blank_values: bool + :param strict_parsing: flag indicating what to do with parsing errors. + If false (the default), errors are silently ignored. If true, + errors raise a ValueError exception. + :type strict_parsing: bool + :param encoding: specify how to decode percent-encoded sequences + into Unicode characters, as accepted by the bytes.decode() method. + :type encoding: str + :param errors: specify how to decode percent-encoded sequences + into Unicode characters, as accepted by the bytes.decode() method. + :type errors: str + :return: RequestParameters + """ + if not self.parsed_args[ + (keep_blank_values, strict_parsing, encoding, errors) + ]: if self.query_string: - self.parsed_args = RequestParameters( - parse_qs(self.query_string) + self.parsed_args[ + (keep_blank_values, strict_parsing, encoding, errors) + ] = RequestParameters( + parse_qs( + qs=self.query_string, + keep_blank_values=keep_blank_values, + strict_parsing=strict_parsing, + encoding=encoding, + errors=errors, + ) ) - else: - self.parsed_args = RequestParameters() - return self.parsed_args + + return self.parsed_args[ + (keep_blank_values, strict_parsing, encoding, errors) + ] + + args = property(get_args) @property - def raw_args(self): + def raw_args(self) -> dict: + if self.app.debug: # pragma: no cover + warnings.simplefilter("default") + warnings.warn( + "Use of raw_args will be deprecated in " + "the future versions. Please use args or query_args " + "properties instead", + DeprecationWarning, + ) return {k: v[0] for k, v in self.args.items()} + def get_query_args( + self, + keep_blank_values: bool = False, + strict_parsing: bool = False, + encoding: str = "utf-8", + errors: str = "replace", + ) -> list: + """ + Method to parse `query_string` using `urllib.parse.parse_qsl`. + This methods is used by `query_args` property. + Can be used directly if you need to change default parameters. + :param keep_blank_values: flag indicating whether blank values in + percent-encoded queries should be treated as blank strings. + A true value indicates that blanks should be retained as blank + strings. The default false value indicates that blank values + are to be ignored and treated as if they were not included. + :type keep_blank_values: bool + :param strict_parsing: flag indicating what to do with parsing errors. + If false (the default), errors are silently ignored. If true, + errors raise a ValueError exception. + :type strict_parsing: bool + :param encoding: specify how to decode percent-encoded sequences + into Unicode characters, as accepted by the bytes.decode() method. + :type encoding: str + :param errors: specify how to decode percent-encoded sequences + into Unicode characters, as accepted by the bytes.decode() method. + :type errors: str + :return: list + """ + if not self.parsed_not_grouped_args[ + (keep_blank_values, strict_parsing, encoding, errors) + ]: + if self.query_string: + self.parsed_not_grouped_args[ + (keep_blank_values, strict_parsing, encoding, errors) + ] = parse_qsl( + qs=self.query_string, + keep_blank_values=keep_blank_values, + strict_parsing=strict_parsing, + encoding=encoding, + errors=errors, + ) + return self.parsed_not_grouped_args[ + (keep_blank_values, strict_parsing, encoding, errors) + ] + + query_args = property(get_query_args) + @property def cookies(self): if self._cookies is None: diff --git a/tests/test_requests.py b/tests/test_requests.py index e10c4538..2f587361 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -10,7 +10,7 @@ import pytest from sanic import Sanic from sanic import Blueprint from sanic.exceptions import ServerError -from sanic.request import DEFAULT_HTTP_CONTENT_TYPE +from sanic.request import DEFAULT_HTTP_CONTENT_TYPE, RequestParameters from sanic.response import json, text from sanic.testing import HOST, PORT @@ -130,6 +130,9 @@ def test_query_string(app): assert request.args.get("test1") == "1" assert request.args.get("test2") == "false" + assert request.args.getlist("test2") == ["false", "true"] + assert request.args.getlist("test1") == ["1"] + assert request.args.get("test3", default="My value") == "My value" def test_uri_template(app): @@ -646,6 +649,77 @@ def test_request_raw_args(app): assert request.raw_args == params +def test_request_query_args(app): + # test multiple params with the same key + params = [('test', 'value1'), ('test', 'value2')] + + @app.get("/") + def handler(request): + return text("pass") + + request, response = app.test_client.get("/", params=params) + + assert request.query_args == params + + # test cached value + assert request.parsed_not_grouped_args[(False, False, "utf-8", "replace")] == request.query_args + + # test params directly in the url + request, response = app.test_client.get("/?test=value1&test=value2") + + assert request.query_args == params + + # test unique params + params = [('test1', 'value1'), ('test2', 'value2')] + + request, response = app.test_client.get("/", params=params) + + assert request.query_args == params + + # test no params + request, response = app.test_client.get("/") + + assert not request.query_args + + +def test_request_query_args_custom_parsing(app): + @app.get("/") + def handler(request): + return text("pass") + + request, response = app.test_client.get("/?test1=value1&test2=&test3=value3") + + assert request.get_query_args( + keep_blank_values=True + ) == [ + ('test1', 'value1'), ('test2', ''), ('test3', 'value3') + ] + assert request.query_args == [ + ('test1', 'value1'), ('test3', 'value3') + ] + assert request.get_query_args( + keep_blank_values=False + ) == [ + ('test1', 'value1'), ('test3', 'value3') + ] + + assert request.get_args( + keep_blank_values=True + ) == RequestParameters( + {"test1": ["value1"], "test2": [""], "test3": ["value3"]} + ) + + assert request.args == RequestParameters( + {"test1": ["value1"], "test3": ["value3"]} + ) + + assert request.get_args( + keep_blank_values=False + ) == RequestParameters( + {"test1": ["value1"], "test3": ["value3"]} + ) + + def test_request_cookies(app): cookies = {"test": "OK"}