mirror of
https://github.com/kennethreitz/httpbin.git
synced 2026-06-05 23:00:18 +00:00
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:
@@ -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
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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&duration=5&code=200"><code>/drip?numbytes=n&duration=s&delay=s&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&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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user