feat(req+resp): Add support for Content-Range units other than 'bytes'
In RFC 2616 it says that "The only range unit defined by HTTP/1.1 is "bytes". HTTP/1.1 implementations MAY ignore ranges specified using other units.". Current falcon implementation only accepts 'bytes', which does not work with some client implementations using different range units. For example, dojo/store/JsonRest requires the server to support 'items' as range unit. Add a request property 'range_unit', which contains the range-unit parsed from Range header. It enhances format_range to accept a range unit as optional parameter in range tuple. This range unit default is set to 'bytes' to not break current API. Closes #570
This commit is contained in:
committed by
Kurt Griffiths
parent
960c23613c
commit
581f23bc83
@@ -158,6 +158,8 @@ class Request(object):
|
|||||||
Only continous ranges are supported (e.g., "bytes=0-0,-1" would
|
Only continous ranges are supported (e.g., "bytes=0-0,-1" would
|
||||||
result in an HTTPBadRequest exception when the attribute is
|
result in an HTTPBadRequest exception when the attribute is
|
||||||
accessed.)
|
accessed.)
|
||||||
|
range_unit (str): Unit of the range parsed from the value of the
|
||||||
|
Range header, or ``None`` if the header is missing
|
||||||
if_match (str): Value of the If-Match header, or ``None`` if the
|
if_match (str): Value of the If-Match header, or ``None`` if the
|
||||||
header is missing.
|
header is missing.
|
||||||
if_none_match (str): Value of the If-None-Match header, or ``None``
|
if_none_match (str): Value of the If-None-Match header, or ``None``
|
||||||
@@ -371,20 +373,20 @@ class Request(object):
|
|||||||
def range(self):
|
def range(self):
|
||||||
try:
|
try:
|
||||||
value = self.env['HTTP_RANGE']
|
value = self.env['HTTP_RANGE']
|
||||||
if value.startswith('bytes='):
|
if '=' in value:
|
||||||
value = value[6:]
|
unit, sep, req_range = value.partition('=')
|
||||||
else:
|
else:
|
||||||
msg = "The value must be prefixed with 'bytes='"
|
msg = "The value must be prefixed with a range unit, e.g. 'bytes='"
|
||||||
raise HTTPInvalidHeader(msg, 'Range')
|
raise HTTPInvalidHeader(msg, 'Range')
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if ',' in value:
|
if ',' in req_range:
|
||||||
msg = 'The value must be a continuous byte range.'
|
msg = 'The value must be a continuous range.'
|
||||||
raise HTTPInvalidHeader(msg, 'Range')
|
raise HTTPInvalidHeader(msg, 'Range')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
first, sep, last = value.partition('-')
|
first, sep, last = req_range.partition('-')
|
||||||
|
|
||||||
if not sep:
|
if not sep:
|
||||||
raise ValueError()
|
raise ValueError()
|
||||||
@@ -394,16 +396,30 @@ class Request(object):
|
|||||||
elif last:
|
elif last:
|
||||||
return (-int(last), -1)
|
return (-int(last), -1)
|
||||||
else:
|
else:
|
||||||
msg = 'The byte offsets are missing.'
|
msg = 'The range offsets are missing.'
|
||||||
raise HTTPInvalidHeader(msg, 'Range')
|
raise HTTPInvalidHeader(msg, 'Range')
|
||||||
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
href = 'http://goo.gl/zZ6Ey'
|
href = 'http://goo.gl/zZ6Ey'
|
||||||
href_text = 'HTTP/1.1 Range Requests'
|
href_text = 'HTTP/1.1 Range Requests'
|
||||||
msg = ('It must be a byte range formatted according to RFC 2616.')
|
msg = ('It must be a range formatted according to RFC 7233.')
|
||||||
raise HTTPInvalidHeader(msg, 'Range', href=href,
|
raise HTTPInvalidHeader(msg, 'Range', href=href,
|
||||||
href_text=href_text)
|
href_text=href_text)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def range_unit(self):
|
||||||
|
try:
|
||||||
|
value = self.env['HTTP_RANGE']
|
||||||
|
|
||||||
|
if '=' in value:
|
||||||
|
unit, sep, req_range = value.partition('=')
|
||||||
|
return unit
|
||||||
|
else:
|
||||||
|
msg = "The value must be prefixed with a range unit, e.g. 'bytes='"
|
||||||
|
raise HTTPInvalidHeader(msg, 'Range')
|
||||||
|
except KeyError:
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def app(self):
|
def app(self):
|
||||||
return self.env.get('SCRIPT_NAME', '')
|
return self.env.get('SCRIPT_NAME', '')
|
||||||
|
|||||||
@@ -509,10 +509,11 @@ class Response(object):
|
|||||||
'Content-Range',
|
'Content-Range',
|
||||||
"""A tuple to use in constructing a value for the Content-Range header.
|
"""A tuple to use in constructing a value for the Content-Range header.
|
||||||
|
|
||||||
The tuple has the form (*start*, *end*, *length*), where *start* and
|
The tuple has the form (*start*, *end*, *length*, [*unit*]), where *start* and
|
||||||
*end* designate the byte range (inclusive), and *length* is the
|
*end* designate the range (inclusive), and *length* is the
|
||||||
total number of bytes, or '\*' if unknown. You may pass ``int``'s for
|
total length, or '\*' if unknown. You may pass ``int``'s for
|
||||||
these numbers (no need to convert to ``str`` beforehand).
|
these numbers (no need to convert to ``str`` beforehand). The optional value
|
||||||
|
*unit* describes the range unit and defaults to 'bytes'
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
You only need to use the alternate form, 'bytes \*/1234', for
|
You only need to use the alternate form, 'bytes \*/1234', for
|
||||||
|
|||||||
@@ -51,12 +51,16 @@ def format_range(value):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
value: ``tuple`` passed to `req.range`
|
value: ``tuple`` passed to `req.range`
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# PERF: Concatenation is faster than % string formatting as well
|
# PERF: Concatenation is faster than % string formatting as well
|
||||||
# as ''.join() in this case.
|
# as ''.join() in this case.
|
||||||
return ('bytes ' +
|
if len(value) == 4:
|
||||||
|
unit = value[3] + ' '
|
||||||
|
else:
|
||||||
|
unit = 'bytes '
|
||||||
|
|
||||||
|
return (unit +
|
||||||
str(value[0]) + '-' +
|
str(value[0]) + '-' +
|
||||||
str(value[1]) + '/' +
|
str(value[1]) + '/' +
|
||||||
str(value[2]))
|
str(value[2]))
|
||||||
|
|||||||
@@ -64,8 +64,11 @@ class HeaderHelpersResource:
|
|||||||
resp.location = '/things/87'
|
resp.location = '/things/87'
|
||||||
resp.content_location = '/things/78'
|
resp.content_location = '/things/78'
|
||||||
|
|
||||||
# bytes 0-499/10240
|
if req.range_unit is None or req.range_unit == 'bytes':
|
||||||
resp.content_range = (0, 499, 10 * 1024)
|
# bytes 0-499/10240
|
||||||
|
resp.content_range = (0, 499, 10 * 1024)
|
||||||
|
else:
|
||||||
|
resp.content_range = (0, 25, 100, req.range_unit)
|
||||||
|
|
||||||
self.resp = resp
|
self.resp = resp
|
||||||
|
|
||||||
@@ -347,6 +350,21 @@ class TestHeaders(testing.TestBase):
|
|||||||
self.assertIn(('content-range', 'bytes 0-499/10240'),
|
self.assertIn(('content-range', 'bytes 0-499/10240'),
|
||||||
self.srmock.headers)
|
self.srmock.headers)
|
||||||
|
|
||||||
|
resp.content_range = (0, 499, 10 * 1024, 'bytes')
|
||||||
|
self.assertEqual('bytes 0-499/10240', resp.content_range)
|
||||||
|
self.assertIn(('content-range', 'bytes 0-499/10240'),
|
||||||
|
self.srmock.headers)
|
||||||
|
|
||||||
|
req_headers = {
|
||||||
|
'Range': 'items=0-25',
|
||||||
|
}
|
||||||
|
self.simulate_request(self.test_route, headers=req_headers)
|
||||||
|
|
||||||
|
resp.content_range = (0, 25, 100, 'items')
|
||||||
|
self.assertEqual('items 0-25/100', resp.content_range)
|
||||||
|
self.assertIn(('content-range', 'items 0-25/100'),
|
||||||
|
self.srmock.headers)
|
||||||
|
|
||||||
# Check for duplicate headers
|
# Check for duplicate headers
|
||||||
hist = defaultdict(lambda: 0)
|
hist = defaultdict(lambda: 0)
|
||||||
for name, value in self.srmock.headers:
|
for name, value in self.srmock.headers:
|
||||||
|
|||||||
@@ -403,6 +403,24 @@ class TestReqVars(testing.TestBase):
|
|||||||
req = Request(testing.create_environ())
|
req = Request(testing.create_environ())
|
||||||
self.assertIs(req.range, None)
|
self.assertIs(req.range, None)
|
||||||
|
|
||||||
|
def test_range_unit(self):
|
||||||
|
headers = {'Range': 'bytes=10-'}
|
||||||
|
req = Request(testing.create_environ(headers=headers))
|
||||||
|
self.assertEqual(req.range, (10, -1))
|
||||||
|
self.assertEqual(req.range_unit, 'bytes')
|
||||||
|
|
||||||
|
headers = {'Range': 'items=10-'}
|
||||||
|
req = Request(testing.create_environ(headers=headers))
|
||||||
|
self.assertEqual(req.range, (10, -1))
|
||||||
|
self.assertEqual(req.range_unit, 'items')
|
||||||
|
|
||||||
|
headers = {'Range': ''}
|
||||||
|
req = Request(testing.create_environ(headers=headers))
|
||||||
|
self.assertRaises(falcon.HTTPInvalidHeader, lambda: req.range_unit)
|
||||||
|
|
||||||
|
req = Request(testing.create_environ())
|
||||||
|
self.assertIs(req.range_unit, None)
|
||||||
|
|
||||||
def test_range_invalid(self):
|
def test_range_invalid(self):
|
||||||
headers = {'Range': 'bytes=10240'}
|
headers = {'Range': 'bytes=10240'}
|
||||||
req = Request(testing.create_environ(headers=headers))
|
req = Request(testing.create_environ(headers=headers))
|
||||||
@@ -410,7 +428,7 @@ class TestReqVars(testing.TestBase):
|
|||||||
|
|
||||||
headers = {'Range': 'bytes=-'}
|
headers = {'Range': 'bytes=-'}
|
||||||
expected_desc = ('The value provided for the Range header is '
|
expected_desc = ('The value provided for the Range header is '
|
||||||
'invalid. The byte offsets are missing.')
|
'invalid. The range offsets are missing.')
|
||||||
self._test_error_details(headers, 'range',
|
self._test_error_details(headers, 'range',
|
||||||
falcon.HTTPInvalidHeader,
|
falcon.HTTPInvalidHeader,
|
||||||
'Invalid header value', expected_desc)
|
'Invalid header value', expected_desc)
|
||||||
@@ -461,8 +479,8 @@ class TestReqVars(testing.TestBase):
|
|||||||
|
|
||||||
headers = {'Range': 'bytes=x-y'}
|
headers = {'Range': 'bytes=x-y'}
|
||||||
expected_desc = ('The value provided for the Range header is '
|
expected_desc = ('The value provided for the Range header is '
|
||||||
'invalid. It must be a byte range formatted '
|
'invalid. It must be a range formatted '
|
||||||
'according to RFC 2616.')
|
'according to RFC 7233.')
|
||||||
self._test_error_details(headers, 'range',
|
self._test_error_details(headers, 'range',
|
||||||
falcon.HTTPInvalidHeader,
|
falcon.HTTPInvalidHeader,
|
||||||
'Invalid header value', expected_desc)
|
'Invalid header value', expected_desc)
|
||||||
@@ -470,7 +488,7 @@ class TestReqVars(testing.TestBase):
|
|||||||
headers = {'Range': 'bytes=0-0,-1'}
|
headers = {'Range': 'bytes=0-0,-1'}
|
||||||
expected_desc = ('The value provided for the Range '
|
expected_desc = ('The value provided for the Range '
|
||||||
'header is invalid. The value must be a '
|
'header is invalid. The value must be a '
|
||||||
'continuous byte range.')
|
'continuous range.')
|
||||||
self._test_error_details(headers, 'range',
|
self._test_error_details(headers, 'range',
|
||||||
falcon.HTTPInvalidHeader,
|
falcon.HTTPInvalidHeader,
|
||||||
'Invalid header value', expected_desc)
|
'Invalid header value', expected_desc)
|
||||||
@@ -478,7 +496,7 @@ class TestReqVars(testing.TestBase):
|
|||||||
headers = {'Range': '10-'}
|
headers = {'Range': '10-'}
|
||||||
expected_desc = ("The value provided for the Range "
|
expected_desc = ("The value provided for the Range "
|
||||||
"header is invalid. The value must be "
|
"header is invalid. The value must be "
|
||||||
"prefixed with 'bytes='")
|
"prefixed with a range unit, e.g. 'bytes='")
|
||||||
self._test_error_details(headers, 'range',
|
self._test_error_details(headers, 'range',
|
||||||
falcon.HTTPInvalidHeader,
|
falcon.HTTPInvalidHeader,
|
||||||
'Invalid header value', expected_desc)
|
'Invalid header value', expected_desc)
|
||||||
|
|||||||
Reference in New Issue
Block a user