Add test suite for ASGI handlers

This commit is contained in:
Andrew Godwin 2016-02-05 15:53:01 -08:00
parent 17e9824f71
commit 5cd2cbdfee
6 changed files with 315 additions and 30 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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")

View File

@ -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)

View File

@ -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.