diff --git a/channels/handler.py b/channels/handler.py index 33068a5..4dc83e7 100644 --- a/channels/handler.py +++ b/channels/handler.py @@ -9,6 +9,7 @@ from django.core.handlers import base from django.core import signals from django.core.urlresolvers import set_script_prefix from django.utils.functional import cached_property +from django.utils import six logger = logging.getLogger('django.request') @@ -21,8 +22,10 @@ class AsgiRequest(http.HttpRequest): def __init__(self, message): self.message = message - self.reply_channel = self.message['reply_channel'] + self.reply_channel = self.message.reply_channel self._content_length = 0 + self._post_parse_error = False + self.resolver_match = None # Path info self.path = self.message['path'] self.script_name = self.message.get('root_path', '') @@ -63,7 +66,7 @@ class AsgiRequest(http.HttpRequest): except (ValueError, TypeError): pass # Body handling - self._body = message.get("body", "") + self._body = message.get("body", b"") if message.get("body_channel", None): while True: # Get the next chunk from the request body channel @@ -78,6 +81,7 @@ class AsgiRequest(http.HttpRequest): # Exit loop if this was the last if not chunk.get("more_content", False): break + assert isinstance(self._body, six.binary_type), "Body is not bytes" # Other bits self.resolver_match = None @@ -97,7 +101,13 @@ class AsgiRequest(http.HttpRequest): def _set_post(self, post): self._post = post + def _get_files(self): + if not hasattr(self, '_files'): + self._load_post_and_files() + return self._files + POST = property(_get_post, _set_post) + FILES = property(_get_files) @cached_property def COOKIES(self): @@ -114,6 +124,9 @@ class AsgiHandler(base.BaseHandler): initLock = Lock() request_class = AsgiRequest + # Size to chunk response bodies into for multiple response messages + chunk_size = 512 * 1024 + def __call__(self, message): # Set up middleware if needed. We couldn't do this earlier, because # settings weren't available. @@ -148,7 +161,9 @@ class AsgiHandler(base.BaseHandler): """ Encodes a Django HTTP response into an ASGI http.response message(s). """ - # Collect cookies into headers + # Collect cookies into headers. + # Note that we have to preserve header case as there are some non-RFC + # compliant clients that want things like Content-Type correct. Ugh. response_headers = [(str(k), str(v)) for k, v in response.items()] for c in response.cookies.values(): response_headers.append((str('Set-Cookie'), str(c.output(header='')))) @@ -186,14 +201,13 @@ class AsgiHandler(base.BaseHandler): Yields (chunk, last_chunk) tuples. """ - CHUNK_SIZE = 512 * 1024 position = 0 while position < len(data): yield ( - data[position:position+CHUNK_SIZE], - (position + CHUNK_SIZE) >= len(data), + data[position:position + self.chunk_size], + (position + self.chunk_size) >= len(data), ) - position += CHUNK_SIZE + position += self.chunk_size class ViewConsumer(object): @@ -205,5 +219,5 @@ class ViewConsumer(object): self.handler = AsgiHandler() def __call__(self, message): - for reply_message in self.handler(message.content): + for reply_message in self.handler(message): message.reply_channel.send(reply_message) diff --git a/channels/message.py b/channels/message.py index 93b19fd..53a36cc 100644 --- a/channels/message.py +++ b/channels/message.py @@ -12,16 +12,20 @@ class Message(object): to use to reply to this message's end user, if that makes sense. """ - class Requeue(Exception): - """ - Raise this while processing a message to requeue it back onto the - channel. Useful if you're manually ensuring partial ordering, etc. - """ - pass - - def __init__(self, content, channel, channel_layer, reply_channel=None): + def __init__(self, content, channel, channel_layer): self.content = content self.channel = channel self.channel_layer = channel_layer - if reply_channel: - self.reply_channel = Channel(reply_channel, channel_layer=self.channel_layer) + if content.get("reply_channel", None): + self.reply_channel = Channel( + content["reply_channel"], + channel_layer=self.channel_layer, + ) + else: + self.reply_channel = None + + def __getitem__(self, key): + return self.content[key] + + def get(self, key, default=None): + return self.content.get(key, default) diff --git a/channels/tests/test_handler.py b/channels/tests/test_handler.py new file mode 100644 index 0000000..192c036 --- /dev/null +++ b/channels/tests/test_handler.py @@ -0,0 +1,90 @@ +from __future__ import unicode_literals +from django.test import SimpleTestCase +from django.http import HttpResponse + +from asgiref.inmemory import ChannelLayer +from channels.handler import AsgiHandler +from channels.message import Message + + +class FakeAsgiHandler(AsgiHandler): + """ + Handler subclass that just returns a premade response rather than + go into the view subsystem. + """ + + chunk_size = 30 + + def __init__(self, response): + assert isinstance(response, HttpResponse) + self._response = response + super(FakeAsgiHandler, self).__init__() + + def get_response(self, request): + return self._response + + +class HandlerTests(SimpleTestCase): + """ + Tests that the handler works correctly and round-trips things into a + correct response. + """ + + def setUp(self): + """ + Make an in memory channel layer for testing + """ + self.channel_layer = ChannelLayer() + self.make_message = lambda m, c: Message(m, c, self.channel_layer) + + def test_basic(self): + """ + Tests a simple request + """ + # Make stub request and desired response + message = self.make_message({ + "reply_channel": "test", + "http_version": "1.1", + "method": "GET", + "path": "/test/", + }, "test") + response = HttpResponse(b"Hi there!", content_type="text/plain") + # Run the handler + handler = FakeAsgiHandler(response) + reply_messages = list(handler(message)) + # Make sure we got the right number of messages + self.assertEqual(len(reply_messages), 1) + reply_message = reply_messages[0] + # Make sure the message looks correct + self.assertEqual(reply_message["content"], b"Hi there!") + self.assertEqual(reply_message["status"], 200) + self.assertEqual(reply_message["status_text"], "OK") + self.assertEqual(reply_message.get("more_content", False), False) + self.assertEqual( + reply_message["headers"], + [("Content-Type", b"text/plain")], + ) + + def test_large(self): + """ + Tests a large response (will need chunking) + """ + # Make stub request and desired response + message = self.make_message({ + "reply_channel": "test", + "http_version": "1.1", + "method": "GET", + "path": "/test/", + }, "test") + response = HttpResponse(b"Thefirstthirtybytesisrighthereandhereistherest") + # Run the handler + handler = FakeAsgiHandler(response) + reply_messages = list(handler(message)) + # Make sure we got the right number of messages + self.assertEqual(len(reply_messages), 2) + # Make sure the messages look correct + self.assertEqual(reply_messages[0]["content"], b"Thefirstthirtybytesisrighthere") + self.assertEqual(reply_messages[0]["status"], 200) + self.assertEqual(reply_messages[0]["more_content"], True) + self.assertEqual(reply_messages[1]["content"], b"andhereistherest") + self.assertEqual(reply_messages[1].get("more_content", False), False) diff --git a/channels/tests/test_request.py b/channels/tests/test_request.py new file mode 100644 index 0000000..a103b28 --- /dev/null +++ b/channels/tests/test_request.py @@ -0,0 +1,178 @@ +from __future__ import unicode_literals +from django.test import SimpleTestCase +from django.utils import six + +from asgiref.inmemory import ChannelLayer +from channels.handler import AsgiRequest +from channels.message import Message + + +class RequestTests(SimpleTestCase): + """ + Tests that ASGI request handling correctly decodes HTTP requests. + """ + + def setUp(self): + """ + Make an in memory channel layer for testing + """ + self.channel_layer = ChannelLayer() + self.make_message = lambda m, c: Message(m, c, self.channel_layer) + + def test_basic(self): + """ + Tests that the handler can decode the most basic request message, + with all optional fields omitted. + """ + message = self.make_message({ + "reply_channel": "test-reply", + "http_version": "1.1", + "method": "GET", + "path": "/test/", + }, "test") + request = AsgiRequest(message) + self.assertEqual(request.path, "/test/") + self.assertEqual(request.method, "GET") + self.assertFalse(request.body) + self.assertNotIn("HTTP_HOST", request.META) + self.assertNotIn("REMOTE_ADDR", request.META) + self.assertNotIn("REMOTE_HOST", request.META) + self.assertNotIn("REMOTE_PORT", request.META) + self.assertNotIn("SERVER_NAME", request.META) + self.assertNotIn("SERVER_PORT", request.META) + self.assertFalse(request.GET) + self.assertFalse(request.POST) + self.assertFalse(request.COOKIES) + + def test_extended(self): + """ + Tests a more fully-featured GET request + """ + message = self.make_message({ + "reply_channel": "test", + "http_version": "1.1", + "method": "GET", + "path": "/test2/", + "query_string": b"x=1&y=foo%20bar+baz", + "headers": { + "host": b"example.com", + "cookie": b"test-time=1448995585123; test-value=yeah", + }, + "client": ["10.0.0.1", 1234], + "server": ["10.0.0.2", 80], + }, "test") + request = AsgiRequest(message) + self.assertEqual(request.path, "/test2/") + self.assertEqual(request.method, "GET") + self.assertFalse(request.body) + self.assertEqual(request.META["HTTP_HOST"], "example.com") + self.assertEqual(request.META["REMOTE_ADDR"], "10.0.0.1") + self.assertEqual(request.META["REMOTE_HOST"], "10.0.0.1") + self.assertEqual(request.META["REMOTE_PORT"], 1234) + self.assertEqual(request.META["SERVER_NAME"], "10.0.0.2") + self.assertEqual(request.META["SERVER_PORT"], 80) + self.assertEqual(request.GET["x"], "1") + self.assertEqual(request.GET["y"], "foo bar baz") + self.assertEqual(request.COOKIES["test-time"], "1448995585123") + self.assertEqual(request.COOKIES["test-value"], "yeah") + self.assertFalse(request.POST) + + def test_post_single(self): + """ + Tests a POST body contained within a single message. + """ + message = self.make_message({ + "reply_channel": "test", + "http_version": "1.1", + "method": "POST", + "path": "/test2/", + "query_string": b"django=great", + "body": b"ponies=are+awesome", + "headers": { + "host": b"example.com", + "content-type": b"application/x-www-form-urlencoded", + "content-length": b"18", + }, + }, "test") + request = AsgiRequest(message) + self.assertEqual(request.path, "/test2/") + self.assertEqual(request.method, "POST") + self.assertEqual(request.body, b"ponies=are+awesome") + self.assertEqual(request.META["HTTP_HOST"], "example.com") + self.assertEqual(request.META["CONTENT_TYPE"], "application/x-www-form-urlencoded") + self.assertEqual(request.GET["django"], "great") + self.assertEqual(request.POST["ponies"], "are awesome") + with self.assertRaises(KeyError): + request.POST["django"] + with self.assertRaises(KeyError): + request.GET["ponies"] + + def test_post_multiple(self): + """ + Tests a POST body across multiple messages (first part in 'body'). + """ + message = self.make_message({ + "reply_channel": "test", + "http_version": "1.1", + "method": "POST", + "path": "/test/", + "body": b"there_a", + "body_channel": "test-input", + "headers": { + "host": b"example.com", + "content-type": b"application/x-www-form-urlencoded", + "content-length": b"21", + }, + }, "test") + self.channel_layer.send("test-input", { + "content": b"re=fou", + "more_content": True, + }) + self.channel_layer.send("test-input", { + "content": b"r+lights", + }) + request = AsgiRequest(message) + self.assertEqual(request.method, "POST") + self.assertEqual(request.body, b"there_are=four+lights") + self.assertEqual(request.META["CONTENT_TYPE"], "application/x-www-form-urlencoded") + self.assertEqual(request.POST["there_are"], "four lights") + + def test_post_files(self): + """ + Tests POSTing files using multipart form data and multiple messages, + with no body in the initial message. + """ + body = ( + b'--BOUNDARY\r\n' + + b'Content-Disposition: form-data; name="title"\r\n\r\n' + + b'My First Book\r\n' + + b'--BOUNDARY\r\n' + + b'Content-Disposition: form-data; name="pdf"; filename="book.pdf"\r\n\r\n' + + b'FAKEPDFBYTESGOHERE' + + b'--BOUNDARY--' + ) + message = self.make_message({ + "reply_channel": "test", + "http_version": "1.1", + "method": "POST", + "path": "/test/", + "body_channel": "test-input", + "headers": { + "content-type": b"multipart/form-data; boundary=BOUNDARY", + "content-length": six.binary_type(len(body)), + }, + }, "test") + self.channel_layer.send("test-input", { + "content": body[:20], + "more_content": True, + }) + self.channel_layer.send("test-input", { + "content": body[20:], + }) + request = AsgiRequest(message) + self.assertEqual(request.method, "POST") + self.assertEqual(len(request.body), len(body)) + self.assertTrue(request.META["CONTENT_TYPE"].startswith("multipart/form-data")) + self.assertFalse(request._post_parse_error) + self.assertEqual(request.POST["title"], "My First Book") + self.assertEqual(request.FILES["pdf"].read(), "FAKEPDFBYTESGOHERE") diff --git a/channels/worker.py b/channels/worker.py index 73fc5c6..0111ff2 100644 --- a/channels/worker.py +++ b/channels/worker.py @@ -35,7 +35,6 @@ class Worker(object): content=content, channel=channel, channel_layer=self.channel_layer, - reply_channel=content.get("reply_channel", None), ) # Handle the message consumer = self.channel_layer.registry.consumer_for_channel(channel) diff --git a/docs/asgi.rst b/docs/asgi.rst index 09dde5f..019f7a3 100644 --- a/docs/asgi.rst +++ b/docs/asgi.rst @@ -431,30 +431,30 @@ Keys: * ``method``: Unicode string HTTP method name, uppercased. * ``scheme``: Unicode string URL scheme portion (likely ``http`` or ``https``). - Optional (but must not be empty), default is ``http``. + Optional (but must not be empty), default is ``"http"``. * ``path``: Byte string HTTP path from URL. * ``query_string``: Byte string URL portion after the ``?``. Optional, default - is empty string. + is ``""``. * ``root_path``: Byte string that indicates the root path this application is mounted at; same as ``SCRIPT_NAME`` in WSGI. Optional, defaults - to empty string. + to ``""``. * ``headers``: Dict of ``{name: value}``, where ``name`` is the lowercased - HTTP header name as byte string and ``value`` is the header value as a byte + HTTP header name as unicode string and ``value`` is the header value as a byte string. If multiple headers with the same name are received, they should be concatenated into a single header as per RFC 2616. Header names containing - underscores should be discarded by the server. + underscores should be discarded by the server. Optional, defaults to ``{}``. -* ``body``: Body of the request, as a byte string. Optional, defaults to empty - string. If ``body_channel`` is set, treat as start of body and concatenate +* ``body``: Body of the request, as a byte string. Optional, defaults to ``""``. + If ``body_channel`` is set, treat as start of body and concatenate on further chunks. * ``body_channel``: Single-reader channel name that contains Request Body Chunk messages representing a large request body. - Optional, defaults to None. Chunks append to ``body`` if set. Presence of + Optional, defaults to ``None``. Chunks append to ``body`` if set. Presence of a channel indicates at least one Request Body Chunk message needs to be read, and then further consumption keyed off of the ``more_content`` key in those messages. @@ -501,9 +501,9 @@ Keys: Ignored for HTTP/2 clients. Optional, default should be based on ``status`` or left as empty string if no default found. -* ``headers``: A list of ``[name, value]`` pairs, where ``name`` is the byte - string header name, and ``value`` is the byte string header value. Order - should be preserved in the HTTP response. +* ``headers``: A list of ``[name, value]`` pairs, where ``name`` is the + unicode string header name, and ``value`` is the byte string + header value. Order should be preserved in the HTTP response. * ``content``: Byte string of HTTP body content. Optional, defaults to empty string.