Added a /range-request endpoint

This endpoint conforms to RFC7233.
http://svn.tools.ietf.org/svn/wg/httpbis/specs/rfc7233.html

It is functionally very similar to /stream-bytes, but it also
allows specifying a "Range" header, which allows the client
to ask for a specific portion of the resource.

I didn't add this functionality to /stream-bytes so as not
to break compatibility with any clients that expect range
requests to fail on that endpoint. Perhaps I'm just being
overly cautious.
This commit is contained in:
David Shirley
2015-01-02 13:22:42 -08:00
parent 6b7676ba80
commit e73f34e335
5 changed files with 200 additions and 2 deletions
+2
View File
@@ -40,6 +40,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-request/: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.
@@ -74,6 +75,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-request/:n: http://httpbin.org/range-request/1024
.. _/html: http://httpbin.org/html
.. _/robots.txt: http://httpbin.org/robots.txt
.. _/deny: http://httpbin.org/deny
+73 -2
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, parse_request_range, check_basic_auth, check_digest_auth, secure_cookie, H, ROBOT_TXT, ANGRY_ASCII
from .utils import weighted_choice
from .structures import CaseInsensitiveDict
@@ -537,10 +537,81 @@ def stream_random_bytes(n):
yield(bytes(chunks))
headers = {'Transfer-Encoding': 'chunked',
'Content-Type': 'application/octet-stream'}
'Content-Type': 'application/octet-stream' }
return Response(generate_bytes(), headers=headers)
@app.route('/range-request/<int:numbytes>')
def range_request(numbytes):
"""Streams n random bytes generated with given seed, at given chunk size per packet."""
numbytes = min(numbytes, 100 * 1024) # set 100KB limit
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 = 0
if numbytes:
pause = duration / numbytes
request_headers = get_headers()
request_range = parse_request_range(request_headers['range'], numbytes)
first_byte_pos = request_range[0]
last_byte_pos = request_range[1]
if first_byte_pos is None and last_byte_pos is None:
first_byte_pos = 0
last_byte_pos = numbytes - 1
elif first_byte_pos is None:
first_byte_pos = max(0, numbytes - last_byte_pos)
last_byte_pos = numbytes - 1
elif last_byte_pos is None:
last_byte_pos = numbytes - 1
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-request%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):
time.sleep(pause)
# 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))
chunks = bytearray()
if 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-request%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):
+40
View File
@@ -333,3 +333,43 @@ 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, upper_bound):
""" 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)
+1
View File
@@ -34,6 +34,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-request/1024"><code>/range-request/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-request/1234')
self.assertEqual(response1.status_code, 200)
self.assertEqual(response1.headers.get('ETag'), 'range-request1234')
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-request/1234')
self.assertEqual(response2.status_code, 200)
self.assertEqual(response2.headers.get('ETag'), 'range-request1234')
self.assertEqual(response1.get_data(), response2.get_data())
def test_request_range_with_parameters(self):
response = self.app.get(
'/range-request/100?duration=1.5&chunk_size=5',
headers={ 'Range': 'bytes=10-24' }
)
self.assertEqual(response.status_code, 206)
self.assertEqual(response.headers.get('ETag'), 'range-request100')
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')
def test_request_range_first_15_bytes(self):
response = self.app.get(
'/range-request/1000',
headers={ 'Range': 'bytes=0-15' }
)
self.assertEqual(response.status_code, 206)
self.assertEqual(response.headers.get('ETag'), 'range-request1000')
self.assertEqual(response.get_data(), 'abcdefghijklmnop')
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-request/26',
headers={ 'Range': 'bytes=20-' }
)
self.assertEqual(response.status_code, 206)
self.assertEqual(response.headers.get('ETag'), 'range-request26')
self.assertEqual(response.get_data(), 'uvwxyz')
self.assertEqual(response.headers.get('Content-range'), 'bytes 20-25/26')
def test_request_range_suffix(self):
response = self.app.get(
'/range-request/26',
headers={ 'Range': 'bytes=-5' }
)
self.assertEqual(response.status_code, 206)
self.assertEqual(response.headers.get('ETag'), 'range-request26')
self.assertEqual(response.get_data(), 'vwxyz')
self.assertEqual(response.headers.get('Content-range'), 'bytes 21-25/26')
def test_request_out_of_bounds(self):
response = self.app.get(
'/range-request/26',
headers={ 'Range': 'bytes=10-5',
}
)
self.assertEqual(response.status_code, 416)
self.assertEqual(response.headers.get('ETag'), 'range-request26')
self.assertEqual(len(response.get_data()), 0)
self.assertEqual(response.headers.get('Content-range'), 'bytes */26')
response = self.app.get(
'/range-request/26',
headers={ 'Range': 'bytes=32-40',
}
)
self.assertEqual(response.status_code, 416)
response = self.app.get(
'/range-request/26',
headers={ 'Range': 'bytes=0-40',
}
)
self.assertEqual(response.status_code, 416)
if __name__ == '__main__':
unittest.main()