From e73f34e335aeb9a76a308d5643b16b8384d883ad Mon Sep 17 00:00:00 2001 From: David Shirley Date: Fri, 2 Jan 2015 13:22:42 -0800 Subject: [PATCH] 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. --- README.rst | 2 + httpbin/core.py | 75 +++++++++++++++++++++++++++- httpbin/helpers.py | 40 +++++++++++++++ httpbin/templates/httpbin.1.html | 1 + test_httpbin.py | 84 ++++++++++++++++++++++++++++++++ 5 files changed, 200 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index f0daa61..cb12fc2 100644 --- a/README.rst +++ b/README.rst @@ -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 diff --git a/httpbin/core.py b/httpbin/core.py index af22285..846d0e2 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, 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/') +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//') def link_page(n, offset): diff --git a/httpbin/helpers.py b/httpbin/helpers.py index e0dcad8..15805fb 100644 --- a/httpbin/helpers.py +++ b/httpbin/helpers.py @@ -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) + diff --git a/httpbin/templates/httpbin.1.html b/httpbin/templates/httpbin.1.html index ee96383..32eb6ea 100644 --- a/httpbin/templates/httpbin.1.html +++ b/httpbin/templates/httpbin.1.html @@ -34,6 +34,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-request/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..ca12b23 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-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()