diff --git a/falcon/api.py b/falcon/api.py index 8d62173..ece6dbe 100644 --- a/falcon/api.py +++ b/falcon/api.py @@ -104,8 +104,8 @@ class API(object): See also: :ref:`Routing `. Attributes: - req_options (RequestOptions): A set of behavioral options related to - incoming requests. + req_options: A set of behavioral options related to incoming + requests. See also: :py:class:`~.RequestOptions` """ # PERF(kgriffs): Reference via self since that is faster than diff --git a/falcon/request.py b/falcon/request.py index 851898c..3c8c33a 100644 --- a/falcon/request.py +++ b/falcon/request.py @@ -50,10 +50,6 @@ TRUE_STRINGS = ('true', 'True', 'yes', '1') FALSE_STRINGS = ('false', 'False', 'no', '0') WSGI_CONTENT_HEADERS = ('CONTENT_TYPE', 'CONTENT_LENGTH') - -_maybe_wrap_wsgi_stream = True - - # PERF(kgriffs): Avoid an extra namespace lookup when using these functions strptime = datetime.strptime now = datetime.now @@ -173,7 +169,38 @@ class Request(object): the header is missing. content_length (int): Value of the Content-Length header converted to an ``int``, or ``None`` if the header is missing. - stream: File-like object for reading the body of the request, if any. + stream: File-like input object for reading the body of the + request, if any. Since this object is provided by the WSGI + server itself, rather than by Falcon, it may behave + differently depending on how you host your app. For example, + attempting to read more bytes than are expected (as + determined by the Content-Length header) may or may not + block indefinitely. It's a good idea to test your WSGI + server to find out how it behaves. + + This can be particulary problematic when a request body is + expected, but none is given. In this case, the following + call blocks under certain WSGI servers:: + + # Blocks if Content-Length is 0 + data = req.stream.read() + + The workaround is fairly straightforward, if verbose:: + + # If Content-Length happens to be 0, or the header is + # missing altogether, this will not block. + data = req.stream.read(req.content_length or 0) + + Alternatively, when passing the stream directly to a + consumer, it may be necessary to branch off the + value of the Content-Length header:: + + if req.content_length: + doc = json.load(req.stream) + + For a slight performance cost, you may instead wish to use + :py:attr:`bounded_stream`, which wraps the native WSGI + input object to normalize its behavior. Note: If an HTML form is POSTed to the API using the @@ -184,6 +211,23 @@ class Request(object): and merge them into the query string parameters. In this case, the stream will be left at EOF. + bounded_stream: File-like wrapper around `stream` to normalize + certain differences between the native input objects + employed by different WSGI servers. In particular, + `bounded_stream` is aware of the expected Content-Length of + the body, and will never block on out-of-bounds reads, + assuming the client does not stall while transmitting the + data to the server. + + For example, the following will not block when + Content-Length is 0 or the header is missing altogether:: + + data = req.bounded_stream.read() + + This is also safe:: + + doc = json.load(req.stream) + date (datetime): Value of the Date header, converted to a ``datetime`` instance. The header value is assumed to conform to RFC 1123. @@ -246,6 +290,7 @@ class Request(object): 'path', 'query_string', 'stream', + '_bounded_stream', 'context', '_wsgierrors', 'options', @@ -258,14 +303,14 @@ class Request(object): # Child classes may override this context_type = None - def __init__(self, env, options=None): - global _maybe_wrap_wsgi_stream + _wsgi_input_type_known = False + _always_wrap_wsgi_input = False + def __init__(self, env, options=None): self.env = env self.options = options if options else RequestOptions() self._wsgierrors = env['wsgi.errors'] - self.stream = env['wsgi.input'] self.method = env['REQUEST_METHOD'] self.uri_template = None @@ -316,21 +361,30 @@ class Request(object): # NOTE(kgriffs): Wrap wsgi.input if needed to make read() more robust, # normalizing semantics between, e.g., gunicorn and wsgiref. - if _maybe_wrap_wsgi_stream: - if isinstance(self.stream, (NativeStream, InputWrapper,)): - self._wrap_stream() - else: - # PERF(kgriffs): If self.stream does not need to be wrapped - # this time, it never needs to be wrapped since the server - # will continue using the same type for wsgi.input. - _maybe_wrap_wsgi_stream = False + if not Request._wsgi_input_type_known: + Request._always_wrap_wsgi_input = isinstance( + env['wsgi.input'], + (NativeStream, InputWrapper) + ) + + Request._wsgi_input_type_known = True + + if Request._always_wrap_wsgi_input: + # TODO(kgriffs): In Falcon 2.0, stop wrapping stream since it is + # less useful now that we have bounded_stream. + self.stream = self._get_wrapped_wsgi_input() + self._bounded_stream = self.stream + else: + self.stream = env['wsgi.input'] + self._bounded_stream = None # Lazy wrapping # PERF(kgriffs): Technically, we should spend a few more # cycles and parse the content type for real, but # this heuristic will work virtually all the time. if (self.options.auto_parse_form_urlencoded and self.content_type is not None and - 'application/x-www-form-urlencoded' in self.content_type): + 'application/x-www-form-urlencoded' in self.content_type and + self.method == 'POST'): self._parse_form_urlencoded() if self.context_type is None: @@ -402,6 +456,13 @@ class Request(object): return value_as_int + @property + def bounded_stream(self): + if self._bounded_stream is None: + self._bounded_stream = self._get_wrapped_wsgi_input() + + return self._bounded_stream + @property def date(self): return self.get_header_as_datetime('Date') @@ -750,9 +811,12 @@ class Request(object): Note: If an HTML form is POSTed to the API using the - *application/x-www-form-urlencoded* media type, the - parameters from the request body will be merged into - the query string parameters. + *application/x-www-form-urlencoded* media type, Falcon can + automatically parse the parameters from the request body + and merge them into the query string parameters. To enable + this functionality, set + :py:attr:`~.RequestOptions.auto_parse_form_urlencoded` to + ``True`` via :any:`API.req_options`. If a key appears more than once in the form data, one of the values will be returned as a string, but it is undefined which @@ -1126,7 +1190,7 @@ class Request(object): # Helpers # ------------------------------------------------------------------------ - def _wrap_stream(self): + def _get_wrapped_wsgi_input(self): try: content_length = self.content_length or 0 @@ -1137,18 +1201,14 @@ class Request(object): # but it had an invalid value. Assume no content. content_length = 0 - self.stream = helpers.Body(self.stream, content_length) + return helpers.BoundedStream(self.env['wsgi.input'], content_length) def _parse_form_urlencoded(self): - # NOTE(kgriffs): This assumes self.stream has been patched - # above in the case of wsgiref, so that self.content_length - # is not needed. Normally we just avoid accessing - # self.content_length, because it is a little expensive - # to call. We could cache self.content_length, but the - # overhead to do that won't usually be helpful, since - # content length will only ever be read once per - # request in most cases. - body = self.stream.read() + content_length = self.content_length + if not content_length: + return + + body = self.stream.read(content_length) # NOTE(kgriffs): According to http://goo.gl/6rlcux the # body should be US-ASCII. Enforcing this also helps @@ -1201,7 +1261,10 @@ class Request(object): # PERF: To avoid typos and improve storage space and speed over a dict. class RequestOptions(object): - """This class is a container for ``Request`` options. + """Defines a set of configurable request options. + + An instance of this class is exposed via :any:`API.req_options` for + configuring certain :py:class:`~.Request` behaviors. Attributes: keep_blank_qs_values (bool): Set to ``True`` to keep query string diff --git a/falcon/request_helpers.py b/falcon/request_helpers.py index dda7837..c027e72 100644 --- a/falcon/request_helpers.py +++ b/falcon/request_helpers.py @@ -36,7 +36,7 @@ def header_property(wsgi_name): return property(fget) -class Body(object): +class BoundedStream(object): """Wrap *wsgi.input* streams to make them more robust. ``socket._fileobject`` and ``io.BufferedReader`` are sometimes used @@ -74,11 +74,11 @@ class Body(object): """Helper function for proxing reads to the underlying stream. Args: - size (int): Maximum number of bytes/characters to read. - Will be coerced, if None or -1, to `self.stream_len`. Will - likewise be coerced if greater than `self.stream_len`, so - that if the stream doesn't follow standard io semantics, - the read won't block. + size (int): Maximum number of bytes to read. Will be + coerced, if None or -1, to the number of remaining bytes + in the stream. Will likewise be coerced if greater than + the number of remaining bytes, to avoid making a + blocking call to the wrapped stream. target (callable): Once `size` has been fixed up, this function will be called to actually do the work. @@ -137,3 +137,7 @@ class Body(object): """ return self._read(hint, self.stream.readlines) + + +# NOTE(kgriffs): Alias for backwards-compat +Body = BoundedStream diff --git a/falcon/testing/base.py b/falcon/testing/base.py index 4d1e3f6..261cc86 100644 --- a/falcon/testing/base.py +++ b/falcon/testing/base.py @@ -27,7 +27,10 @@ from falcon.testing.helpers import create_environ from falcon.testing.srmock import StartResponseMock -class TestBase(unittest.TestCase): +# NOTE(kgriffs): Since this class is deprecated and we will be using it +# less and less for Falcon's own tests, coverage may be reduced, hence +# the pragma to ignore coverage errors from now on. +class TestBase(unittest.TestCase): # pragma nocover """Extends :py:mod:`unittest` to support WSGI functional testing. Warning: diff --git a/falcon/testing/resource.py b/falcon/testing/resource.py index c6442f9..393f33c 100644 --- a/falcon/testing/resource.py +++ b/falcon/testing/resource.py @@ -66,9 +66,10 @@ class SimpleTestResource(object): as needed to test middleware, hooks, and the Falcon framework itself. - Only the ``on_get()`` responder is implemented; when adding - additional responders in child classes, they can be decorated - with the :py:meth:`falcon.testing.capture_responder_args` hook in + Only noop ``on_get()`` and ``on_post()`` responders are implemented; + when overriding these, or adding additional responders in child + classes, they can be decorated with the + :py:meth:`falcon.testing.capture_responder_args` hook in order to capture the *req*, *resp*, and *params* arguments that are passed to the responder. Responders may also be decorated with the :py:meth:`falcon.testing.set_resp_defaults` hook in order to @@ -108,11 +109,20 @@ class SimpleTestResource(object): else: self._default_body = body + self.captured_req = None + self.captured_resp = None + self.captured_kwargs = None + @falcon.before(capture_responder_args) @falcon.before(set_resp_defaults) def on_get(self, req, resp, **kwargs): pass + @falcon.before(capture_responder_args) + @falcon.before(set_resp_defaults) + def on_post(self, req, resp, **kwargs): + pass + class TestResource(object): """Mock resource for functional testing. diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b752e0a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +import pytest + +import falcon + + +# NOTE(kgriffs): Some modules actually run a wsgiref server, so +# to ensure we reset the detection for the other modules, we just +# run this fixture before each one is tested. +@pytest.fixture(autouse=True, scope='module') +def reset_request_stream_detection(): + falcon.Request._wsgi_input_type_known = False + falcon.Request._always_wrap_wsgi_input = False diff --git a/tests/test_query_params.py b/tests/test_query_params.py index 62c906d..0b6a053 100644 --- a/tests/test_query_params.py +++ b/tests/test_query_params.py @@ -12,14 +12,14 @@ import falcon.testing as testing class _TestQueryParams(testing.TestBase): def before(self): - self.resource = testing.TestResource() + self.resource = testing.SimpleTestResource() self.api.add_route('/', self.resource) def test_none(self): query_string = '' self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req store = {} self.assertIs(req.get_param('marker'), None) self.assertIs(req.get_param('limit', store), None) @@ -32,7 +32,7 @@ class _TestQueryParams(testing.TestBase): query_string = 'marker=' self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req self.assertIs(req.get_param('marker'), None) store = {} @@ -43,7 +43,7 @@ class _TestQueryParams(testing.TestBase): query_string = 'marker=deadbeef&limit=25' self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req store = {} self.assertEqual(req.get_param('marker', store=store) or 'nada', 'deadbeef') @@ -56,7 +56,7 @@ class _TestQueryParams(testing.TestBase): query_string = 'id=23,42&q=%e8%b1%86+%e7%93%a3' self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req # NOTE(kgriffs): For lists, get_param will return one of the # elements, but which one it will choose is undefined. @@ -71,7 +71,7 @@ class _TestQueryParams(testing.TestBase): query_string = 'id=23,42,,&id=2' self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req self.assertEqual(req.params['id'], [u'23,42,,', u'2']) self.assertIn(req.get_param('id'), [u'23,42,,', u'2']) @@ -83,7 +83,7 @@ class _TestQueryParams(testing.TestBase): query_string = 'id=23,42,,&id=2' self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req self.assertEqual(req.params['id'], [u'23', u'42', u'2']) self.assertIn(req.get_param('id'), [u'23', u'42', u'2']) @@ -102,7 +102,7 @@ class _TestQueryParams(testing.TestBase): self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req self.assertIn(req.get_param('colors'), 'red,green,blue') self.assertEqual(req.get_param_as_list('colors'), [u'red,green,blue']) @@ -124,7 +124,7 @@ class _TestQueryParams(testing.TestBase): self.simulate_request('/', query_string=query_string) self.assertEqual(self.srmock.status, falcon.HTTP_200) - req = self.resource.req + req = self.resource.captured_req self.assertEqual(req.get_param('x'), '% % %') self.assertEqual(req.get_param('y'), 'peregrine') self.assertEqual(req.get_param('z'), '%a%z%zz%1 e') @@ -135,7 +135,7 @@ class _TestQueryParams(testing.TestBase): '_thing=42&_charset_=utf-8') self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req self.assertEqual(req.get_param('p'), '0') self.assertEqual(req.get_param('p1'), '23') self.assertEqual(req.get_param('2p'), 'foo') @@ -153,7 +153,7 @@ class _TestQueryParams(testing.TestBase): query_string = '' self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req try: getattr(req, method_name)('marker', required=True) @@ -168,7 +168,7 @@ class _TestQueryParams(testing.TestBase): query_string = 'marker=deadbeef&limit=25' self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req try: req.get_param_as_int('marker') @@ -230,7 +230,7 @@ class _TestQueryParams(testing.TestBase): query_string = 'marker=deadbeef&pos=-7' self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req self.assertEqual(req.get_param_as_int('pos'), -7) self.assertEqual( @@ -260,7 +260,7 @@ class _TestQueryParams(testing.TestBase): 't1=True&f1=False&t2=yes&f2=no&blank&one=1&zero=0') self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req self.assertRaises(falcon.HTTPBadRequest, req.get_param_as_bool, 'bogus') @@ -296,7 +296,7 @@ class _TestQueryParams(testing.TestBase): query_string='blank&blank2=', ) - req = self.resource.req + req = self.resource.captured_req self.assertEqual(req.get_param('blank'), '') self.assertEqual(req.get_param('blank2'), '') self.assertRaises(falcon.HTTPInvalidParam, req.get_param_as_bool, @@ -316,7 +316,7 @@ class _TestQueryParams(testing.TestBase): '&thing_two=1&thing_two=&thing_two=3') self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req # NOTE(kgriffs): For lists, get_param will return one of the # elements, but which one it will choose is undefined. @@ -366,7 +366,7 @@ class _TestQueryParams(testing.TestBase): query_string=query_string ) - req = self.resource.req + req = self.resource.captured_req # NOTE(kgriffs): For lists, get_param will return one of the # elements, but which one it will choose is undefined. @@ -412,7 +412,7 @@ class _TestQueryParams(testing.TestBase): query_string = 'coord=1.4,13,15.1&limit=100&things=4,,1' self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req # NOTE(kgriffs): For lists, get_param will return one of the # elements, but which one it will choose is undefined. @@ -443,7 +443,7 @@ class _TestQueryParams(testing.TestBase): query_string = 'ant=4&bee=3&cat=2&dog=1' self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req self.assertEqual( sorted(req.params.items()), [('ant', '4'), ('bee', '3'), ('cat', '2'), ('dog', '1')]) @@ -452,7 +452,7 @@ class _TestQueryParams(testing.TestBase): query_string = 'ant=1&ant=2&bee=3&cat=6&cat=5&cat=4' self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req # By definition, we cannot guarantee which of the multiple keys will # be returned by .get_param(). self.assertIn(req.get_param('ant'), ('1', '2')) @@ -464,20 +464,20 @@ class _TestQueryParams(testing.TestBase): def test_multiple_keys_as_bool(self): query_string = 'ant=true&ant=yes&ant=True' self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req self.assertEqual(req.get_param_as_bool('ant'), True) def test_multiple_keys_as_int(self): query_string = 'ant=1&ant=2&ant=3' self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req self.assertIn(req.get_param_as_int('ant'), (1, 2, 3)) def test_multiple_form_keys_as_list(self): query_string = 'ant=1&ant=2&bee=3&cat=6&cat=5&cat=4' self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req # There are two 'ant' keys. self.assertEqual(req.get_param_as_list('ant'), ['1', '2']) # There is only one 'bee' key.. @@ -489,14 +489,14 @@ class _TestQueryParams(testing.TestBase): date_value = '2015-04-20' query_string = 'thedate={0}'.format(date_value) self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req self.assertEqual(req.get_param_as_date('thedate'), date(2015, 4, 20)) def test_get_date_missing_param(self): query_string = 'notthedate=2015-04-20' self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req self.assertEqual(req.get_param_as_date('thedate'), None) @@ -505,7 +505,7 @@ class _TestQueryParams(testing.TestBase): query_string = 'thedate={0}'.format(date_value) format_string = '%Y%m%d' self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req self.assertEqual(req.get_param_as_date('thedate', format_string=format_string), date(2015, 4, 20)) @@ -514,7 +514,7 @@ class _TestQueryParams(testing.TestBase): date_value = '2015-04-20' query_string = 'thedate={0}'.format(date_value) self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req store = {} req.get_param_as_date('thedate', store=store) self.assertNotEqual(len(store), 0) @@ -524,7 +524,7 @@ class _TestQueryParams(testing.TestBase): query_string = 'thedate={0}'.format(date_value) format_string = '%Y%m%d' self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req self.assertRaises(HTTPInvalidParam, req.get_param_as_date, 'thedate', format_string=format_string) @@ -532,7 +532,7 @@ class _TestQueryParams(testing.TestBase): payload_dict = {'foo': 'bar'} query_string = 'payload={0}'.format(json.dumps(payload_dict)) self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req self.assertEqual(req.get_param_as_dict('payload'), payload_dict) @@ -540,7 +540,7 @@ class _TestQueryParams(testing.TestBase): payload_dict = {'foo': 'bar'} query_string = 'notthepayload={0}'.format(json.dumps(payload_dict)) self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req self.assertEqual(req.get_param_as_dict('payload'), None) @@ -548,7 +548,7 @@ class _TestQueryParams(testing.TestBase): payload_dict = {'foo': 'bar'} query_string = 'payload={0}'.format(json.dumps(payload_dict)) self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req store = {} req.get_param_as_dict('payload', store=store) self.assertNotEqual(len(store), 0) @@ -557,7 +557,7 @@ class _TestQueryParams(testing.TestBase): payload_dict = 'foobar' query_string = 'payload={0}'.format(payload_dict) self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req self.assertRaises(HTTPInvalidParam, req.get_param_as_dict, 'payload') @@ -568,23 +568,42 @@ class PostQueryParams(_TestQueryParams): self.api.req_options.auto_parse_form_urlencoded = True def simulate_request(self, path, query_string, **kwargs): - headers = {'Content-Type': 'application/x-www-form-urlencoded'} + + headers = kwargs.setdefault('headers', {}) + headers['Content-Type'] = 'application/x-www-form-urlencoded' + super(PostQueryParams, self).simulate_request( - path, body=query_string, headers=headers, **kwargs) + path, + method='POST', + body=query_string, + **kwargs + ) def test_non_ascii(self): value = u'\u8c46\u74e3' query_string = b'q=' + value.encode('utf-8') self.simulate_request('/', query_string=query_string) - req = self.resource.req + req = self.resource.captured_req + self.assertIs(req.get_param('q'), None) + + def test_empty_body(self): + self.simulate_request('/', query_string=None) + + req = self.resource.captured_req + self.assertIs(req.get_param('q'), None) + + def test_empty_body_no_content_length(self): + self.simulate_request('/', query_string=None) + + req = self.resource.captured_req self.assertIs(req.get_param('q'), None) def test_explicitly_disable_auto_parse(self): self.api.req_options.auto_parse_form_urlencoded = False self.simulate_request('/', query_string='q=42') - req = self.resource.req + req = self.resource.captured_req self.assertIs(req.get_param('q'), None) @@ -596,11 +615,11 @@ class GetQueryParams(_TestQueryParams): class PostQueryParamsDefaultBehavior(testing.TestBase): def test_dont_auto_parse_by_default(self): - self.resource = testing.TestResource() + self.resource = testing.SimpleTestResource() self.api.add_route('/', self.resource) headers = {'Content-Type': 'application/x-www-form-urlencoded'} self.simulate_request('/', body='q=42', headers=headers) - req = self.resource.req + req = self.resource.captured_req self.assertIs(req.get_param('q'), None) diff --git a/tests/test_request_body.py b/tests/test_request_body.py index fef730f..42f57e4 100644 --- a/tests/test_request_body.py +++ b/tests/test_request_body.py @@ -1,8 +1,4 @@ import io -import threading -from wsgiref import simple_server - -import requests import falcon from falcon import request_helpers @@ -64,42 +60,21 @@ class TestRequestBody(testing.TestBase): self.assertEqual(stream.tell(), expected_len) - def test_read_socket_body(self): - expected_body = testing.rand_string(SIZE_1_KB / 2, SIZE_1_KB) + def test_bounded_stream_property_empty_body(self): + """Test that we can get a bounded stream outside of wsgiref.""" - def server(): - class Echo(object): - def on_post(self, req, resp): - # wsgiref socket._fileobject blocks when len not given, - # but Falcon is smarter than that. :D - body = req.stream.read() - resp.body = body + environ = testing.create_environ() + req = falcon.Request(environ) - def on_put(self, req, resp): - # wsgiref socket._fileobject blocks when len too long, - # but Falcon should work around that for me. - body = req.stream.read(req.content_length + 1) - resp.body = body + bounded_stream = req.bounded_stream - api = falcon.API() - api.add_route('/echo', Echo()) + # NOTE(kgriffs): Verify that we aren't creating a new object + # each time the property is called. Also ensures branch + # coverage of the property implementation. + assert bounded_stream is req.bounded_stream - httpd = simple_server.make_server('127.0.0.1', 8989, api) - httpd.serve_forever() - - thread = threading.Thread(target=server) - thread.daemon = True - thread.start() - - # Let it boot - thread.join(1) - - url = 'http://127.0.0.1:8989/echo' - resp = requests.post(url, data=expected_body) - self.assertEqual(resp.text, expected_body) - - resp = requests.put(url, data=expected_body) - self.assertEqual(resp.text, expected_body) + data = bounded_stream.read() + self.assertEqual(len(data), 0) def test_body_stream_wrapper(self): data = testing.rand_string(SIZE_1_KB / 2, SIZE_1_KB) diff --git a/tests/test_wsgi.py b/tests/test_wsgi.py index 98aa9ae..95caac7 100644 --- a/tests/test_wsgi.py +++ b/tests/test_wsgi.py @@ -7,25 +7,70 @@ try: except ImportError: pass # Jython +import pytest import requests -from testtools.matchers import Equals, MatchesRegex import falcon +from falcon.request_helpers import BoundedStream import falcon.testing as testing _SERVER_HOST = 'localhost' _SERVER_PORT = 9809 _SERVER_BASE_URL = 'http://{0}:{1}/'.format(_SERVER_HOST, _SERVER_PORT) +_SIZE_1_KB = 1024 -def _is_iterable(thing): - try: - for i in thing: - break +@pytest.mark.skipif( + # NOTE(kgriffs): Jython does not support the multiprocessing + # module. We could alternatively implement these tests + # using threads, but then we have to force a garbage + # collection in between each test in order to make + # the server relinquish its socket, and the gc module + # doesn't appear to do anything under Jython. - return True - except: - return False + 'java' in sys.platform, + reason='Incompatible with Jython' +) +@pytest.mark.usefixtures('_setup_wsgi_server') +class TestWSGIServer(object): + + def test_get(self): + resp = requests.get(_SERVER_BASE_URL) + assert resp.status_code == 200 + assert resp.text == '127.0.0.1' + + def test_put(self): + body = '{}' + resp = requests.put(_SERVER_BASE_URL, data=body) + assert resp.status_code == 200 + assert resp.text == '{}' + + def test_head_405(self): + body = '{}' + resp = requests.head(_SERVER_BASE_URL, data=body) + assert resp.status_code == 405 + + def test_post(self): + body = testing.rand_string(_SIZE_1_KB / 2, _SIZE_1_KB) + resp = requests.post(_SERVER_BASE_URL, data=body) + assert resp.status_code == 200 + assert resp.text == body + + def test_post_invalid_content_length(self): + headers = {'Content-Length': 'invalid'} + resp = requests.post(_SERVER_BASE_URL, headers=headers) + assert resp.status_code == 200 + assert resp.text == '' + + def test_post_read_bounded_stream(self): + body = testing.rand_string(_SIZE_1_KB / 2, _SIZE_1_KB) + resp = requests.post(_SERVER_BASE_URL + 'bucket', data=body) + assert resp.status_code == 200 + assert resp.text == body + + def test_post_read_bounded_stream_no_body(self): + resp = requests.post(_SERVER_BASE_URL + 'bucket') + assert not resp.text def _run_server(stop_event): @@ -34,7 +79,7 @@ def _run_server(stop_event): resp.body = req.remote_addr def on_post(self, req, resp): - resp.body = req.stream.read(1000) + resp.body = req.stream.read(_SIZE_1_KB) def on_put(self, req, resp): # NOTE(kgriffs): Test that reading past the end does @@ -44,8 +89,36 @@ def _run_server(stop_event): resp.body = b''.join(req_body) + class Bucket(object): + def on_post(self, req, resp): + # NOTE(kgriffs): The framework automatically detects + # wsgiref's input object type and wraps it; we'll probably + # do away with this at some point, but for now we + # verify the functionality, + assert isinstance(req.stream, BoundedStream) + + # NOTE(kgriffs): Ensure we are reusing the same object for + # the sake of efficiency and to ensure a shared state of the + # stream. (only in the case that we have decided to + # automatically wrap the WSGI input object, i.e. when + # running under wsgiref or similar). + assert req.stream is req.bounded_stream + + # NOTE(kgriffs): This would normally block when + # Content-Length is 0 and the WSGI input object. + # BoundedStream fixes that. This is just a sanity check to + # make sure req.bounded_stream is what we think it is; + # BoundedStream itself has its own unit tests in + # test_request_body.py + resp.body = req.bounded_stream.read() + + # NOTE(kgriffs): No need to also test the same read() for + # req.stream, since we already asserted they are the same + # objects. + api = application = falcon.API() api.add_route('/', Things()) + api.add_route('/bucket', Bucket()) server = make_server(_SERVER_HOST, _SERVER_PORT, application) @@ -53,103 +126,29 @@ def _run_server(stop_event): server.handle_request() -class TestWSGIInterface(testing.TestBase): +@pytest.fixture +def _setup_wsgi_server(): + stop_event = multiprocessing.Event() + process = multiprocessing.Process( + target=_run_server, + args=(stop_event,) + ) - def test_srmock(self): - mock = testing.StartResponseMock() - mock(falcon.HTTP_200, ()) + process.start() - self.assertEqual(falcon.HTTP_200, mock.status) - self.assertEqual(None, mock.exc_info) + # NOTE(kgriffs): Let the server start up + time.sleep(0.2) - mock = testing.StartResponseMock() - exc_info = sys.exc_info() - mock(falcon.HTTP_200, (), exc_info) + yield - self.assertEqual(exc_info, mock.exc_info) + stop_event.set() - def test_pep3333(self): - api = falcon.API() - mock = testing.StartResponseMock() + # NOTE(kgriffs): Pump the request handler loop in case execution + # made it to the next server.handle_request() before we sent the + # event. + try: + requests.get(_SERVER_BASE_URL) + except Exception: + pass # Thread already exited - # Simulate a web request (normally done though a WSGI server) - response = api(testing.create_environ(), mock) - - # Verify that the response is iterable - self.assertTrue(_is_iterable(response)) - - # Make sure start_response was passed a valid status string - self.assertIs(mock.call_count, 1) - self.assertTrue(isinstance(mock.status, str)) - self.assertThat(mock.status, MatchesRegex('^\d+[a-zA-Z\s]+$')) - - # Verify headers is a list of tuples, each containing a pair of strings - self.assertTrue(isinstance(mock.headers, list)) - if len(mock.headers) != 0: - header = mock.headers[0] - self.assertTrue(isinstance(header, tuple)) - self.assertThat(len(header), Equals(2)) - self.assertTrue(isinstance(header[0], str)) - self.assertTrue(isinstance(header[1], str)) - - -class TestWSGIReference(testing.TestBase): - - def before(self): - if 'java' in sys.platform: - # NOTE(kgriffs): Jython does not support the multiprocessing - # module. We could alternatively implement these tests - # using threads, but then we have to force a garbage - # collection in between each test in order to make - # the server relinquish its socket, and the gc module - # doesn't appear to do anything under Jython. - self.skip('Incompatible with Jython') - - self._stop_event = multiprocessing.Event() - self._process = multiprocessing.Process(target=_run_server, - args=(self._stop_event,)) - self._process.start() - - # NOTE(kgriffs): Let the server start up - time.sleep(0.2) - - def after(self): - self._stop_event.set() - - # NOTE(kgriffs): Pump the request handler loop in case execution - # made it to the next server.handle_request() before we sent the - # event. - try: - requests.get(_SERVER_BASE_URL) - except Exception: - pass # Thread already exited - - self._process.join() - - def test_wsgiref_get(self): - resp = requests.get(_SERVER_BASE_URL) - self.assertEqual(resp.status_code, 200) - self.assertEqual(resp.text, '127.0.0.1') - - def test_wsgiref_put(self): - body = '{}' - resp = requests.put(_SERVER_BASE_URL, data=body) - self.assertEqual(resp.status_code, 200) - self.assertEqual(resp.text, '{}') - - def test_wsgiref_head_405(self): - body = '{}' - resp = requests.head(_SERVER_BASE_URL, data=body) - self.assertEqual(resp.status_code, 405) - - def test_wsgiref_post(self): - body = '{}' - resp = requests.post(_SERVER_BASE_URL, data=body) - self.assertEqual(resp.status_code, 200) - self.assertEqual(resp.text, '{}') - - def test_wsgiref_post_invalid_content_length(self): - headers = {'Content-Length': 'invalid'} - resp = requests.post(_SERVER_BASE_URL, headers=headers) - self.assertEqual(resp.status_code, 200) - self.assertEqual(resp.text, '') + process.join() diff --git a/tests/test_wsgi_interface.py b/tests/test_wsgi_interface.py new file mode 100644 index 0000000..98e4b3c --- /dev/null +++ b/tests/test_wsgi_interface.py @@ -0,0 +1,55 @@ +import re +import sys + +import falcon +import falcon.testing as testing + + +class TestWSGIInterface(object): + + def test_srmock(self): + mock = testing.StartResponseMock() + mock(falcon.HTTP_200, ()) + + assert mock.status == falcon.HTTP_200 + assert mock.exc_info is None + + mock = testing.StartResponseMock() + exc_info = sys.exc_info() + mock(falcon.HTTP_200, (), exc_info) + + assert mock.exc_info == exc_info + + def test_pep3333(self): + api = falcon.API() + mock = testing.StartResponseMock() + + # Simulate a web request (normally done though a WSGI server) + response = api(testing.create_environ(), mock) + + # Verify that the response is iterable + assert _is_iterable(response) + + # Make sure start_response was passed a valid status string + assert mock.call_count == 1 + assert isinstance(mock.status, str) + assert re.match('^\d+[a-zA-Z\s]+$', mock.status) + + # Verify headers is a list of tuples, each containing a pair of strings + assert isinstance(mock.headers, list) + if len(mock.headers) != 0: + header = mock.headers[0] + assert isinstance(header, tuple) + assert len(header) == 2 + assert isinstance(header[0], str) + assert isinstance(header[1], str) + + +def _is_iterable(thing): + try: + for i in thing: + break + + return True + except: + return False