diff --git a/falcon/testing/client.py b/falcon/testing/client.py index d265d8997..8c0eb7067 100644 --- a/falcon/testing/client.py +++ b/falcon/testing/client.py @@ -29,7 +29,6 @@ from falcon.asgi_spec import ScopeType from falcon.constants import COMBINED_METHODS -from falcon.constants import MEDIA_JSON from falcon.errors import CompatibilityError from falcon.testing import helpers from falcon.testing.srmock import StartResponseMock @@ -38,7 +37,6 @@ from falcon.util import code_to_http_status from falcon.util import http_cookies from falcon.util import http_date_to_dt -from falcon.util import to_query_str warnings.filterwarnings( 'error', @@ -593,7 +591,7 @@ def simulate_request( cookies=cookies, ) - path, query_string, headers, body, extras = _prepare_sim_args( + path, query_string, headers, body, extras = helpers._prepare_sim_args( path, query_string, params, @@ -609,7 +607,7 @@ def simulate_request( method=method, scheme=protocol, path=path, - query_string=(query_string or ''), + query_string=query_string, headers=headers, body=body, file_wrapper=file_wrapper, @@ -768,7 +766,7 @@ async def _simulate_request_asgi( :py:class:`~.Result`: The result of the request """ - path, query_string, headers, body, extras = _prepare_sim_args( + path, query_string, headers, body, extras = helpers._prepare_sim_args( path, query_string, params, @@ -2143,44 +2141,6 @@ async def __aexit__(self, exc_type, exc, tb): await self._task_req -def _prepare_sim_args( - path, query_string, params, params_csv, content_type, headers, body, json, extras -): - if not path.startswith('/'): - raise ValueError("path must start with '/'") - - if '?' in path: - if query_string or params: - raise ValueError( - 'path may not contain a query string in combination with ' - 'the query_string or params parameters. Please use only one ' - 'way of specifying the query string.' - ) - path, query_string = path.split('?', 1) - elif query_string and query_string.startswith('?'): - raise ValueError("query_string should not start with '?'") - - extras = extras or {} - - if query_string is None: - query_string = to_query_str( - params, - comma_delimited_lists=params_csv, - prefix=False, - ) - - if content_type is not None: - headers = headers or {} - headers['Content-Type'] = content_type - - if json is not None: - body = json_module.dumps(json, ensure_ascii=False) - headers = headers or {} - headers['Content-Type'] = MEDIA_JSON - - return path, query_string, headers, body, extras - - def _is_asgi_app(app): app_args = inspect.getfullargspec(app).args num_app_args = len(app_args) diff --git a/falcon/testing/helpers.py b/falcon/testing/helpers.py index 1b38e975e..ef5cbcdb6 100644 --- a/falcon/testing/helpers.py +++ b/falcon/testing/helpers.py @@ -29,7 +29,7 @@ from enum import Enum import io import itertools -import json +import json as json_module import random import re import socket @@ -43,8 +43,10 @@ from falcon.asgi_spec import EventType from falcon.asgi_spec import ScopeType from falcon.asgi_spec import WSCloseCode +from falcon.constants import MEDIA_JSON from falcon.constants import SINGLETON_HEADERS import falcon.request +from falcon.util import to_query_str from falcon.util import uri from falcon.util.mediatypes import parse_header @@ -531,7 +533,7 @@ async def send_json(self, media: object): media: A JSON-encodable object to send as a TEXT (0x01) payload. """ - text = json.dumps(media) + text = json_module.dumps(media) await self.send_text(text) async def send_msgpack(self, media: object): @@ -600,7 +602,7 @@ async def receive_json(self) -> object: """ text = await self.receive_text() - return json.loads(text) + return json_module.loads(text) async def receive_msgpack(self) -> object: """Receive a message from the app with a MessagePack-encoded BINARY payload. @@ -1240,34 +1242,182 @@ def create_environ( return env -def create_req(options=None, **kwargs) -> falcon.Request: +def create_req( + options=None, + path='/', + query_string='', + http_version='1.1', + scheme='http', + host=DEFAULT_HOST, + port=None, + headers=None, + app=None, # deprecated (?) + body='', + method='GET', + wsgierrors=None, + file_wrapper=None, + remote_addr=None, + root_path=None, + cookies=None, + extras=None, + content_type=None, + json=None, + params=None, + params_csv=True, +) -> falcon.Request: """Create and return a new Request instance. This function can be used to conveniently create a WSGI environ and use it to instantiate a :py:class:`falcon.Request` object in one go. - The arguments for this function are identical to those - of :py:meth:`falcon.testing.create_environ`, except an additional - `options` keyword argument may be set to an instance of - :py:class:`falcon.RequestOptions` to configure certain - aspects of request parsing in lieu of the defaults. + Keyword Arguments: + options (falcon.RequestOptions): An instance of + :py:class:`falcon.RequestOptions` that should be used to determine + certain aspects of request parsing in lieu of the defaults. + path (str): The path for the request (default ``'/'``) + query_string (str): The query string to simulate, without a + leading ``'?'`` (default ``''``). The query string is passed as-is + (it will not be percent-encoded). + http_version (str): The HTTP version to simulate. Must be either + ``'2'``, ``'2.0'``, ``'1.1'``, ``'1.0'``, or ``'1'`` + (default ``'1.1'``). If set to ``'1.0'``, the Host header will not + be added to the scope. + scheme (str): URL scheme, either ``'http'`` or ``'https'`` + (default ``'http'``) + host(str): Hostname for the request (default ``'falconframework.org'``) + port (int): The TCP port to simulate. Defaults to + the standard port used by the given scheme (i.e., 80 for ``'http'`` + and 443 for ``'https'``). A string may also be passed, as long as + it can be parsed as an int. + headers (dict): Headers as a dict-like (Mapping) object, or an + iterable yielding a series of two-member (*name*, *value*) + iterables. Each pair of strings provides the name and value + for an HTTP header. If desired, multiple header values may be + combined into a single (*name*, *value*) pair by joining the values + with a comma when the header in question supports the list + format (see also RFC 7230 and RFC 7231). Header names are not + case-sensitive. + + Note: + If a User-Agent header is not provided, it will default to:: + + f'falcon-client/{falcon.__version__}' + + root_path (str): Value for the ``SCRIPT_NAME`` environ variable, described in + PEP-333: 'The initial portion of the request URL's "path" that + corresponds to the application object, so that the application + knows its virtual "location". This may be an empty string, if the + application corresponds to the "root" of the server.' (default ``''``) + app (str): Deprecated alias for `root_path`. If both kwargs are passed, + `root_path` takes precedence. + body (str): The body of the request (default ``''``). The value will be + encoded as UTF-8 in the WSGI environ. Alternatively, a byte string + may be passed, in which case it will be used as-is. + method (str): The HTTP method to use (default ``'GET'``) + wsgierrors (io): The stream to use as *wsgierrors* + (default ``sys.stderr``) + file_wrapper: Callable that returns an iterable, to be used as + the value for *wsgi.file_wrapper* in the environ. + remote_addr (str): Remote address for the request to use as the + ``'REMOTE_ADDR'`` environ variable (default ``None``) + cookies (dict): Cookies as a dict-like (Mapping) object, or an + iterable yielding a series of two-member (*name*, *value*) + iterables. Each pair of items provides the name and value + for the Set-Cookie header. + extras (dict): Additional values to add to the WSGI + ``environ`` dictionary (default: ``None``) + content_type (str): The value to use for the Content-Type header in + the request. If specified, this value will take precedence over + any value set for the Content-Type header in the + `headers` keyword argument. The ``falcon`` module provides a number + of :ref:`constants for common media types `. + json(JSON serializable): A JSON document to serialize as the + body of the request (default: ``None``). If specified, + overrides `body` and sets the Content-Type header to + ``'application/json'``, overriding any value specified by either + the `content_type` or `headers` arguments. + params (dict): A dictionary of query string parameters, + where each key is a parameter name, and each value is + either a ``str`` or something that can be converted + into a ``str``, or a list of such values. If a ``list``, + the value will be converted to a comma-delimited string + of values (e.g., 'thing=1,2,3'). + params_csv (bool): Set to ``True`` to encode list values + in query string params as comma-separated values + (e.g., 'thing=1,2,3'). Otherwise, parameters will be encoded by + specifying multiple instances of the parameter + (e.g., 'thing=1&thing=2&thing=3'). Defaults to ``False``. """ - env = create_environ(**kwargs) + path, query_string, headers, body, extras = _prepare_sim_args( + path, + query_string, + params, + params_csv, + content_type, + headers, + body, + json, + extras, + ) + + env = create_environ( + method=method, + scheme=scheme, + path=path, + query_string=query_string, + headers=headers, + body=body, + file_wrapper=file_wrapper, + host=host, + remote_addr=remote_addr, + wsgierrors=wsgierrors, + http_version=http_version, + port=port, + root_path=root_path, + cookies=cookies, + ) + + if 'REQUEST_METHOD' in extras and extras['REQUEST_METHOD'] != method: + raise ValueError( + 'WSGI environ extras may not override the request method. ' + 'Please use the method parameter.' + ) + + env.update(extras) + return falcon.request.Request(env, options=options) -def create_asgi_req(body=None, req_type=None, options=None, **kwargs) -> falcon.Request: +def create_asgi_req( + body=None, + req_type=None, + options=None, + path='/', + query_string='', + method='GET', + headers=None, + host=DEFAULT_HOST, + scheme=None, + port=None, + http_version='1.1', + remote_addr=None, + root_path=None, + content_length=None, + include_server=True, + cookies=None, + extras=None, + content_type=None, + json=None, + params=None, + params_csv=True, +) -> falcon.Request: """Create and return a new ASGI Request instance. This function can be used to conveniently create an ASGI scope and use it to instantiate a :py:class:`falcon.asgi.Request` object in one go. - The arguments for this function are identical to those - of :py:meth:`falcon.testing.create_scope`, with the addition of - `body`, `req_type`, and `options` arguments as documented below. - Keyword Arguments: body (bytes): The body data to use for the request (default b''). If the value is a :py:class:`str`, it will be UTF-8 encoded to @@ -1278,9 +1428,118 @@ def create_asgi_req(body=None, req_type=None, options=None, **kwargs) -> falcon. options (falcon.RequestOptions): An instance of :py:class:`falcon.RequestOptions` that should be used to determine certain aspects of request parsing in lieu of the defaults. + path (str): The path for the request (default ``'/'``) + query_string (str): The query string to simulate, without a + leading ``'?'`` (default ``''``). The query string is passed as-is + (it will not be percent-encoded). + method (str): The HTTP method to use (default ``'GET'``) + headers (dict): Headers as a dict-like (Mapping) object, or an + iterable yielding a series of two-member (*name*, *value*) + iterables. Each pair of strings provides the name and value + for an HTTP header. If desired, multiple header values may be + combined into a single (*name*, *value*) pair by joining the values + with a comma when the header in question supports the list + format (see also RFC 7230 and RFC 7231). When the + request will include a body, the Content-Length header should be + included in this list. Header names are not case-sensitive. + + Note: + If a User-Agent header is not provided, it will default to:: + + f'falcon-client/{falcon.__version__}' + + host(str): Hostname for the request (default ``'falconframework.org'``). + This also determines the value of the Host header in the + request. + scheme (str): URL scheme, either ``'http'`` or ``'https'`` + (default ``'http'``) + port (int): The TCP port to simulate. Defaults to + the standard port used by the given scheme (i.e., 80 for ``'http'`` + and 443 for ``'https'``). A string may also be passed, as long as + it can be parsed as an int. + http_version (str): The HTTP version to simulate. Must be either + ``'2'``, ``'2.0'``, ``'1.1'``, ``'1.0'``, or ``'1'`` + (default ``'1.1'``). If set to ``'1.0'``, the Host header will not + be added to the scope. + remote_addr (str): Remote address for the request to use for + the 'client' field in the connection scope (default None) + root_path (str): The root path this application is mounted at; same as + SCRIPT_NAME in WSGI (default ``''``). + content_length (int): The expected content length of the request + body (default ``None``). If specified, this value will be + used to set the Content-Length header in the request. + include_server (bool): Set to ``False`` to not set the 'server' key + in the scope ``dict`` (default ``True``). + cookies (dict): Cookies as a dict-like (Mapping) object, or an + iterable yielding a series of two-member (*name*, *value*) + iterables. Each pair of items provides the name and value + for the 'Set-Cookie' header. + extras (dict): Additional values to add to the ASGI scope + (default: ``None``) + content_type (str): The value to use for the Content-Type header in + the request. If specified, this value will take precedence over + any value set for the Content-Type header in the + `headers` keyword argument. The ``falcon`` module provides a number + of :ref:`constants for common media types `. + json(JSON serializable): A JSON document to serialize as the + body of the request (default: ``None``). If specified, + overrides `body` and sets the Content-Type header to + ``'application/json'``, overriding any value specified by either + the `content_type` or `headers` arguments. + params (dict): A dictionary of query string parameters, + where each key is a parameter name, and each value is + either a ``str`` or something that can be converted + into a ``str``, or a list of such values. If a ``list``, + the value will be converted to a comma-delimited string + of values (e.g., 'thing=1,2,3'). + params_csv (bool): Set to ``True`` to encode list values + in query string params as comma-separated values + (e.g., 'thing=1,2,3'). Otherwise, parameters will be encoded by + specifying multiple instances of the parameter + (e.g., 'thing=1&thing=2&thing=3'). Defaults to ``False``. """ - scope = create_scope(**kwargs) + path, query_string, headers, body, extras = _prepare_sim_args( + path, + query_string, + params, + params_csv, + content_type, + headers, + body, + json, + extras, + ) + + if content_length is None and body is not None: + if isinstance(body, str): + body = body.encode() + + content_length = len(body) + + scope = create_scope( + path=path, + query_string=query_string, + method=method, + headers=headers, + host=host, + scheme=scheme, + port=port, + http_version=http_version, + remote_addr=remote_addr, + root_path=root_path, + content_length=content_length, + cookies=cookies, + include_server=include_server, + ) + + if 'method' in extras and extras['method'] != method.upper(): + raise ValueError( + 'ASGI scope extras may not override the request method. ' + 'Please use the method parameter.' + ) + + scope.update(extras) body = body or b'' disconnect_at = time.time() + 300 @@ -1450,3 +1709,41 @@ def _make_cookie_values(cookies: Dict) -> str: for key, cookie in cookies.items() ] ) + + +def _prepare_sim_args( + path, query_string, params, params_csv, content_type, headers, body, json, extras +): + if path and not path.startswith('/'): + raise ValueError("path must start with '/'") + + if '?' in path: + if query_string or params: + raise ValueError( + 'path may not contain a query string in combination with ' + 'the query_string or params parameters. Please use only one ' + 'way of specifying the query string.' + ) + path, query_string = path.split('?', 1) + elif query_string and query_string.startswith('?'): + raise ValueError("query_string should not start with '?'") + + extras = extras or {} + + if query_string is None: + query_string = to_query_str( + params, + comma_delimited_lists=params_csv, + prefix=False, + ) + + if content_type is not None: + headers = headers or {} + headers['Content-Type'] = content_type + + if json is not None: + body = json_module.dumps(json, ensure_ascii=False) + headers = headers or {} + headers['Content-Type'] = MEDIA_JSON + + return path, query_string, headers, body, extras diff --git a/tests/asgi/test_testing_asgi.py b/tests/asgi/test_testing_asgi.py index f2de736cd..e062c10aa 100644 --- a/tests/asgi/test_testing_asgi.py +++ b/tests/asgi/test_testing_asgi.py @@ -1,4 +1,5 @@ import time +from unittest.mock import MagicMock import pytest @@ -150,6 +151,60 @@ def test_missing_header_is_none(): assert req.auth is None +def test_create_asgi_req_prepares_args(monkeypatch): + kwargs = dict( + path='/test', + query_string='a=0', + params={}, + params_csv=True, + content_type='json', + headers={}, + body=b'', + json='', + extras={}, + ) + + mock = MagicMock(side_effect=testing.helpers._prepare_sim_args) + monkeypatch.setattr(testing.helpers, '_prepare_sim_args', mock) + + testing.create_asgi_req(**kwargs) + mock.assert_called_once_with(*kwargs.values()) + + +@pytest.mark.parametrize( + 'body,content_length', + [ + ('Øresund and Malmö', 19), + (b'Malm\xc3\xb6', 6), + ], +) +def test_create_asgi_req_sets_content_length(monkeypatch, body, content_length): + mock = MagicMock(side_effect=testing.helpers.create_scope) + monkeypatch.setattr(testing.helpers, 'create_scope', mock) + testing.create_asgi_req(body=body) + + assert mock.call_count == 1 + _, kwargs = mock.call_args + assert 'content_length' in kwargs + assert kwargs['content_length'] == content_length + + +def test_create_asgi_req_override_method_with_extras(): + with pytest.raises(ValueError): + testing.create_asgi_req(method='GET', extras={'method': 'PATCH'}) + + +def test_create_asgi_req_adds_extras_to_scope(monkeypatch): + mock = MagicMock() + monkeypatch.setattr(falcon.asgi, 'Request', mock) + + testing.create_asgi_req(extras={'raw_path': 'test'}) + assert mock.call_count == 1 + (scope, _), _ = mock.call_args + assert 'raw_path' in scope + assert scope['raw_path'] == 'test' + + def test_immediate_disconnect(): client = testing.TestClient(_asgi_test_app.application) diff --git a/tests/test_static.py b/tests/test_static.py index 2b9907adb..0ce7fa977 100644 --- a/tests/test_static.py +++ b/tests/test_static.py @@ -89,7 +89,7 @@ def __init__(self, size): '/static/.\x9fssh/authorized_keys', # Reserved characters (~, ?, <, >, :, *, |, ', and ") '/static/~/.ssh/authorized_keys', - '/static/.ssh/authorized_key?', + '/static/.ssh/authorized_key%3F', # %3F is percent encoding for `?` '/static/.ssh/authorized_key>foo', '/static/.ssh/authorized_key|foo', '/static/.ssh/authorized_key