Merge pull request #202 from dshirley/master

Add range request functionality
This commit is contained in:
John Sheehan
2015-02-24 10:48:54 -08:00
5 changed files with 212 additions and 1 deletions
+2
View File
@@ -38,6 +38,7 @@ Endpoint Description
`/stream/:n`_ Streams *n* 100 lines.
`/delay/:n`_ Delays responding for *n* 10 seconds.
`/drip`_ Drips data over a duration after an optional initial delay, then (optionally) returns with the given status code.
`/range/:n`_ Streams *n* bytes, and allows specifying a *Range* header to select a subset of the data. Accepts a *chunk\_size* and request *duration* parameter.
`/html`_ Renders an HTML Page.
`/robots.txt`_ Returns some robots.txt rules.
`/deny`_ Denied by robots.txt file.
@@ -72,6 +73,7 @@ Endpoint Description
.. _/stream/:n: http://httpbin.org/stream/20
.. _/delay/:n: http://httpbin.org/delay/3
.. _/drip: http://httpbin.org/drip?numbytes=5&duration=5&code=200
.. _/range/:n: http://httpbin.org/range/1024
.. _/html: http://httpbin.org/html
.. _/robots.txt: http://httpbin.org/robots.txt
.. _/deny: http://httpbin.org/deny
+68 -1
View File
@@ -21,7 +21,7 @@ from werkzeug.wrappers import BaseResponse
from six.moves import range as xrange
from . import filters
from .helpers import get_headers, status_code, get_dict, check_basic_auth, check_digest_auth, secure_cookie, H, ROBOT_TXT, ANGRY_ASCII
from .helpers import get_headers, status_code, get_dict, get_request_range, check_basic_auth, check_digest_auth, secure_cookie, H, ROBOT_TXT, ANGRY_ASCII
from .utils import weighted_choice
from .structures import CaseInsensitiveDict
@@ -558,6 +558,73 @@ def stream_random_bytes(n):
return Response(generate_bytes(), headers=headers)
@app.route('/range/<int:numbytes>')
def range_request(numbytes):
"""Streams n random bytes generated with given seed, at given chunk size per packet."""
if numbytes <= 0 or numbytes > (100 * 1024):
response = Response(headers={
'ETag' : 'range%d' % numbytes,
'Accept-Ranges' : 'bytes'
})
response.status_code = 404
response.data = 'number of bytes must be in the range (0, 10240]'
return response
params = CaseInsensitiveDict(request.args.items())
if 'chunk_size' in params:
chunk_size = max(1, int(params['chunk_size']))
else:
chunk_size = 10 * 1024
duration = float(params.get('duration', 0))
pause_per_byte = duration / numbytes
request_headers = get_headers()
first_byte_pos, last_byte_pos = get_request_range(request_headers, numbytes)
if first_byte_pos > last_byte_pos or first_byte_pos not in xrange(0, numbytes) or last_byte_pos not in xrange(0, numbytes):
response = Response(headers={
'ETag' : 'range%d' % numbytes,
'Accept-Ranges' : 'bytes',
'Content-Range' : 'bytes */%d' % numbytes
})
response.status_code = 416
return response
def generate_bytes():
chunks = bytearray()
for i in xrange(first_byte_pos, last_byte_pos + 1):
# We don't want the resource to change across requests, so we need
# to use a predictable data generation function
chunks.append(ord('a') + (i % 26))
if len(chunks) == chunk_size:
yield(bytes(chunks))
time.sleep(pause_per_byte * chunk_size)
chunks = bytearray()
if chunks:
time.sleep(pause_per_byte * len(chunks))
yield(bytes(chunks))
content_range = 'bytes %d-%d/%d' % (first_byte_pos, last_byte_pos, numbytes)
response_headers = {
'Transfer-Encoding': 'chunked',
'Content-Type': 'application/octet-stream',
'ETag' : 'range%d' % numbytes,
'Accept-Ranges' : 'bytes',
'Content-Range' : content_range }
response = Response(generate_bytes(), headers=response_headers)
if (first_byte_pos == 0) and (last_byte_pos == (numbytes - 1)):
response.status_code = 200
else:
response.status_code = 206
return response
@app.route('/links/<int:n>/<int:offset>')
def link_page(n, offset):
+57
View File
@@ -340,3 +340,60 @@ def check_digest_auth(user, passwd):
def secure_cookie():
"""Return true if cookie should have secure attribute"""
return request.environ['wsgi.url_scheme'] == 'https'
def __parse_request_range(range_header_text):
""" Return a tuple describing the byte range requested in a GET request
If the range is open ended on the left or right side, then a value of None
will be set.
RFC7233: http://svn.tools.ietf.org/svn/wg/httpbis/specs/rfc7233.html#header.range
Examples:
Range : bytes=1024-
Range : bytes=10-20
Range : bytes=-999
"""
left = None
right = None
if not range_header_text:
return left, right
range_header_text = range_header_text.strip()
if not range_header_text.startswith('bytes'):
return left, right
components = range_header_text.split("=")
if len(components) != 2:
return left, right
components = components[1].split("-")
try:
right = int(components[1])
except:
pass
try:
left = int(components[0])
except:
pass
return left, right
def get_request_range(request_headers, upper_bound):
first_byte_pos, last_byte_pos = __parse_request_range(request_headers['range'])
if first_byte_pos is None and last_byte_pos is None:
# Request full range
first_byte_pos = 0
last_byte_pos = upper_bound - 1
elif first_byte_pos is None:
# Request the last X bytes
first_byte_pos = max(0, upper_bound - last_byte_pos)
last_byte_pos = upper_bound - 1
elif last_byte_pos is None:
# Request the last X bytes
last_byte_pos = upper_bound - 1
return first_byte_pos, last_byte_pos
+1
View File
@@ -32,6 +32,7 @@
<li><a href="/stream/20"><code>/stream/:n</code></a> Streams <em>n</em>100 lines.</li>
<li><a href="/delay/3"><code>/delay/:n</code></a> Delays responding for <em>n</em>10 seconds.</li>
<li><a href="/drip?numbytes=5&amp;duration=5&amp;code=200"><code>/drip?numbytes=n&amp;duration=s&amp;delay=s&amp;code=code</code></a> Drips data over a duration after an optional initial delay, then (optionally) returns with the given status code.</li>
<li><a href="/range/1024"><code>/range/1024?duration=s&amp;chunk_size=code</code></a> Streams <em>n</em> bytes, and allows specifying a <em>Range</em> header to select a subset of the data. Accepts a <em>chunk_size</em> and request <em>duration</em> parameter.</li>
<li><a href="/html" data-bare-link="true"><code>/html</code></a> Renders an HTML Page.</li>
<li><a href="/robots.txt" data-bare-link="true"><code>/robots.txt</code></a> Returns some robots.txt rules.</li>
<li><a href="/deny" data-bare-link="true"><code>/deny</code></a> Denied by robots.txt file.</li>
+84
View File
@@ -335,5 +335,89 @@ class HttpbinTestCase(unittest.TestCase):
response.headers.get('Location'), 'http://localhost/get'
)
def test_request_range(self):
response1 = self.app.get('/range/1234')
self.assertEqual(response1.status_code, 200)
self.assertEqual(response1.headers.get('ETag'), 'range1234')
self.assertEqual(response1.headers.get('Content-range'), 'bytes 0-1233/1234')
self.assertEqual(response1.headers.get('Accept-ranges'), 'bytes')
self.assertEqual(len(response1.get_data()), 1234)
response2 = self.app.get('/range/1234')
self.assertEqual(response2.status_code, 200)
self.assertEqual(response2.headers.get('ETag'), 'range1234')
self.assertEqual(response1.get_data(), response2.get_data())
def test_request_range_with_parameters(self):
response = self.app.get(
'/range/100?duration=1.5&chunk_size=5',
headers={ 'Range': 'bytes=10-24' }
)
self.assertEqual(response.status_code, 206)
self.assertEqual(response.headers.get('ETag'), 'range100')
self.assertEqual(response.headers.get('Content-range'), 'bytes 10-24/100')
self.assertEqual(response.headers.get('Accept-ranges'), 'bytes')
self.assertEqual(response.get_data(), 'klmnopqrstuvwxy'.encode('utf8'))
def test_request_range_first_15_bytes(self):
response = self.app.get(
'/range/1000',
headers={ 'Range': 'bytes=0-15' }
)
self.assertEqual(response.status_code, 206)
self.assertEqual(response.headers.get('ETag'), 'range1000')
self.assertEqual(response.get_data(), 'abcdefghijklmnop'.encode('utf8'))
self.assertEqual(response.headers.get('Content-range'), 'bytes 0-15/1000')
def test_request_range_open_ended_last_6_bytes(self):
response = self.app.get(
'/range/26',
headers={ 'Range': 'bytes=20-' }
)
self.assertEqual(response.status_code, 206)
self.assertEqual(response.headers.get('ETag'), 'range26')
self.assertEqual(response.get_data(), 'uvwxyz'.encode('utf8'))
self.assertEqual(response.headers.get('Content-range'), 'bytes 20-25/26')
def test_request_range_suffix(self):
response = self.app.get(
'/range/26',
headers={ 'Range': 'bytes=-5' }
)
self.assertEqual(response.status_code, 206)
self.assertEqual(response.headers.get('ETag'), 'range26')
self.assertEqual(response.get_data(), 'vwxyz'.encode('utf8'))
self.assertEqual(response.headers.get('Content-range'), 'bytes 21-25/26')
def test_request_out_of_bounds(self):
response = self.app.get(
'/range/26',
headers={ 'Range': 'bytes=10-5',
}
)
self.assertEqual(response.status_code, 416)
self.assertEqual(response.headers.get('ETag'), 'range26')
self.assertEqual(len(response.get_data()), 0)
self.assertEqual(response.headers.get('Content-range'), 'bytes */26')
response = self.app.get(
'/range/26',
headers={ 'Range': 'bytes=32-40',
}
)
self.assertEqual(response.status_code, 416)
response = self.app.get(
'/range/26',
headers={ 'Range': 'bytes=0-40',
}
)
self.assertEqual(response.status_code, 416)
if __name__ == '__main__':
unittest.main()