diff --git a/README.rst b/README.rst index 85067c0..dbd651d 100644 --- a/README.rst +++ b/README.rst @@ -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 diff --git a/httpbin/core.py b/httpbin/core.py index 23c1220..315a92b 100644 --- a/httpbin/core.py +++ b/httpbin/core.py @@ -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/') +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//') def link_page(n, offset): diff --git a/httpbin/helpers.py b/httpbin/helpers.py index 4e931fd..f3cc60b 100644 --- a/httpbin/helpers.py +++ b/httpbin/helpers.py @@ -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 + diff --git a/httpbin/templates/httpbin.1.html b/httpbin/templates/httpbin.1.html index da55af1..2d4ef2e 100644 --- a/httpbin/templates/httpbin.1.html +++ b/httpbin/templates/httpbin.1.html @@ -32,6 +32,7 @@
  • /stream/:n Streams n–100 lines.
  • /delay/:n Delays responding for n–10 seconds.
  • /drip?numbytes=n&duration=s&delay=s&code=code Drips data over a duration after an optional initial delay, then (optionally) returns with the given status code.
  • +
  • /range/1024?duration=s&chunk_size=code 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.
  • diff --git a/test_httpbin.py b/test_httpbin.py index 23e4268..95e366d 100755 --- a/test_httpbin.py +++ b/test_httpbin.py @@ -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()