fix(Response): Headers are case-sensitive
Web developers tend to think of headers being case-insensitive. However, if you try to set the same header twice, with differently-cased header names, Falcon treated them as separate headers. This patch changes falcon.Request such that it now normalizes header names internally, and when performing lookups. Applications shouldn't break due to this change unless they were accessing the private req._headers attribute directly. Closes #185
This commit is contained in:
@@ -108,34 +108,43 @@ class Response(object):
|
||||
Warning: Overwrites the existing value, if any.
|
||||
|
||||
Args:
|
||||
name: Header name to set. Must be of type str or StringType, and
|
||||
only character values 0x00 through 0xFF may be used on
|
||||
platforms that use wide characters.
|
||||
name: Header name to set (case-insensitive). Must be of type str
|
||||
or StringType, and only character values 0x00 through 0xFF
|
||||
may be used on platforms that use wide characters.
|
||||
value: Value for the header. Must be of type str or StringType, and
|
||||
only character values 0x00 through 0xFF may be used on
|
||||
platforms that use wide characters.
|
||||
|
||||
"""
|
||||
|
||||
self._headers[name] = value
|
||||
# NOTE(kgriffs): normalize name by lowercasing it
|
||||
self._headers[name.lower()] = value
|
||||
|
||||
def set_headers(self, headers):
|
||||
"""Set several headers at once. May be faster than set_header().
|
||||
"""Set several headers at once.
|
||||
|
||||
Warning: Overwrites existing values, if any.
|
||||
|
||||
Args:
|
||||
headers: A dict containing header names and values to set. Both
|
||||
names and values must be of type str or StringType, and
|
||||
only character values 0x00 through 0xFF may be used on
|
||||
platforms that use wide characters.
|
||||
headers: A dict containing header names and values to set, or
|
||||
list of (name, value) tuples. A list can be read slightly
|
||||
faster than a dict. Both names and values must be of type
|
||||
str or StringType, and only character values 0x00 through
|
||||
0xFF may be used on platforms that use wide characters.
|
||||
|
||||
Raises:
|
||||
ValueError: headers was not a dictionary or list of tuples.
|
||||
|
||||
"""
|
||||
|
||||
self._headers.update(headers)
|
||||
if isinstance(headers, dict):
|
||||
headers = headers.items()
|
||||
|
||||
# NOTE(kgriffs): We can't use dict.update because we have to
|
||||
# normalize the header names.
|
||||
_headers = self._headers
|
||||
for name, value in headers:
|
||||
_headers[name.lower()] = value
|
||||
|
||||
cache_control = header_property(
|
||||
'Cache-Control',
|
||||
@@ -225,16 +234,18 @@ class Response(object):
|
||||
|
||||
Args:
|
||||
media_type: Default media type to use for the Content-Type
|
||||
header if the header was not set explicitly. (default None)
|
||||
header if the header was not set explicitly (default None).
|
||||
|
||||
"""
|
||||
|
||||
headers = self._headers
|
||||
|
||||
# PERF(kgriffs): Using "in" like this is faster than using
|
||||
# dict.setdefault (tested on py27).
|
||||
set_content_type = (media_type is not None and
|
||||
'Content-Type' not in headers and
|
||||
'content-type' not in headers)
|
||||
|
||||
if set_content_type:
|
||||
headers['Content-Type'] = media_type
|
||||
headers['content-type'] = media_type
|
||||
|
||||
return list(headers.items())
|
||||
|
||||
@@ -29,21 +29,23 @@ def header_property(name, doc, transform=None):
|
||||
as the value of the header (default None)
|
||||
|
||||
"""
|
||||
normalized_name = name.lower()
|
||||
|
||||
def fget(self):
|
||||
try:
|
||||
return self._headers[name]
|
||||
return self._headers[normalized_name]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
if transform is None:
|
||||
def fset(self, value):
|
||||
self._headers[name] = value
|
||||
self._headers[normalized_name] = value
|
||||
else:
|
||||
def fset(self, value):
|
||||
self._headers[name] = transform(value)
|
||||
self._headers[normalized_name] = transform(value)
|
||||
|
||||
def fdel(self):
|
||||
del self._headers[name]
|
||||
del self._headers[normalized_name]
|
||||
|
||||
return property(fget, fset, fdel, doc)
|
||||
|
||||
|
||||
@@ -147,4 +147,4 @@ class TestHooks(testing.TestBase):
|
||||
body = self.simulate_request('/one', method='OPTIONS')
|
||||
self.assertEqual(falcon.HTTP_501, self.srmock.status)
|
||||
self.assertEqual([b'fluffy'], body)
|
||||
self.assertNotIn('Allow', self.srmock.headers_dict)
|
||||
self.assertNotIn('allow', self.srmock.headers_dict)
|
||||
|
||||
@@ -49,7 +49,7 @@ class TestDefaultRouting(testing.TestBase):
|
||||
self.assertEqual(self.srmock.status, falcon.HTTP_405)
|
||||
|
||||
headers = self.srmock.headers
|
||||
allow_header = ('Allow', 'GET, OPTIONS')
|
||||
allow_header = ('allow', 'GET, OPTIONS')
|
||||
|
||||
self.assertThat(headers, Contains(allow_header))
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ class XmlResource:
|
||||
self.content_type = content_type
|
||||
|
||||
def on_get(self, req, resp):
|
||||
resp.set_header('Content-Type', self.content_type)
|
||||
resp.set_header('content-type', self.content_type)
|
||||
|
||||
|
||||
class DefaultContentTypeResource:
|
||||
@@ -44,6 +44,10 @@ class HeaderHelpersResource:
|
||||
else:
|
||||
self.last_modified = datetime.utcnow()
|
||||
|
||||
def _overwrite_headers(self, req, resp):
|
||||
resp.content_type = 'x-falcon/peregrine'
|
||||
resp.cache_control = ['no-store']
|
||||
|
||||
def on_get(self, req, resp):
|
||||
resp.body = "{}"
|
||||
resp.content_type = 'x-falcon/peregrine'
|
||||
@@ -68,13 +72,34 @@ class HeaderHelpersResource:
|
||||
def on_head(self, req, resp):
|
||||
resp.set_header('Content-Type', 'x-swallow/unladen')
|
||||
resp.set_header('X-Auth-Token', 'setecastronomy')
|
||||
resp.set_header('X-Auth-Token', 'toomanysecrets')
|
||||
resp.content_type = 'x-falcon/peregrine'
|
||||
resp.cache_control = ['no-store']
|
||||
resp.set_header('X-AUTH-TOKEN', 'toomanysecrets')
|
||||
|
||||
resp.location = '/things/87'
|
||||
del resp.location
|
||||
|
||||
self._overwrite_headers(req, resp)
|
||||
|
||||
self.resp = resp
|
||||
|
||||
def on_post(self, req, resp):
|
||||
resp.set_headers([
|
||||
('CONTENT-TYPE', 'x-swallow/unladen'),
|
||||
('X-Auth-Token', 'setecastronomy'),
|
||||
('X-AUTH-TOKEN', 'toomanysecrets')
|
||||
])
|
||||
|
||||
self._overwrite_headers(req, resp)
|
||||
|
||||
self.resp = resp
|
||||
|
||||
def on_put(self, req, resp):
|
||||
resp.set_headers({
|
||||
'CONTENT-TYPE': 'x-swallow/unladen',
|
||||
'X-aUTH-tOKEN': 'toomanysecrets'
|
||||
})
|
||||
|
||||
self._overwrite_headers(req, resp)
|
||||
|
||||
self.resp = resp
|
||||
|
||||
|
||||
@@ -115,7 +140,7 @@ class TestHeaders(testing.TestBase):
|
||||
|
||||
# Test Content-Length header set
|
||||
content_length = str(len(self.resource.sample_body))
|
||||
content_length_header = ('Content-Length', content_length)
|
||||
content_length_header = ('content-length', content_length)
|
||||
self.assertThat(headers, Contains(content_length_header))
|
||||
|
||||
def test_default_value(self):
|
||||
@@ -219,8 +244,9 @@ class TestHeaders(testing.TestBase):
|
||||
|
||||
resp_headers = self.srmock.headers
|
||||
|
||||
for h in self.resource.resp_headers.items():
|
||||
self.assertThat(resp_headers, Contains(h))
|
||||
for name, value in self.resource.resp_headers.items():
|
||||
expected = (name.lower(), value)
|
||||
self.assertThat(resp_headers, Contains(expected))
|
||||
|
||||
def test_default_media_type(self):
|
||||
self.resource = DefaultContentTypeResource('Hello world!')
|
||||
@@ -228,7 +254,7 @@ class TestHeaders(testing.TestBase):
|
||||
self.simulate_request(self.test_route)
|
||||
|
||||
content_type = falcon.DEFAULT_MEDIA_TYPE
|
||||
self.assertIn(('Content-Type', content_type), self.srmock.headers)
|
||||
self.assertIn(('content-type', content_type), self.srmock.headers)
|
||||
|
||||
def test_custom_media_type(self):
|
||||
self.resource = DefaultContentTypeResource('Hello world!')
|
||||
@@ -237,7 +263,7 @@ class TestHeaders(testing.TestBase):
|
||||
self.simulate_request(self.test_route)
|
||||
|
||||
content_type = 'application/atom+xml'
|
||||
self.assertIn(('Content-Type', content_type), self.srmock.headers)
|
||||
self.assertIn(('content-type', content_type), self.srmock.headers)
|
||||
|
||||
def test_response_header_helpers_on_get(self):
|
||||
last_modified = datetime(2013, 1, 1, 10, 30, 30)
|
||||
@@ -249,35 +275,35 @@ class TestHeaders(testing.TestBase):
|
||||
|
||||
content_type = 'x-falcon/peregrine'
|
||||
self.assertEqual(content_type, resp.content_type)
|
||||
self.assertIn(('Content-Type', content_type), self.srmock.headers)
|
||||
self.assertIn(('content-type', content_type), self.srmock.headers)
|
||||
|
||||
cache_control = ('public, private, no-cache, no-store, '
|
||||
'must-revalidate, proxy-revalidate, max-age=3600, '
|
||||
's-maxage=60, no-transform')
|
||||
|
||||
self.assertEqual(cache_control, resp.cache_control)
|
||||
self.assertIn(('Cache-Control', cache_control), self.srmock.headers)
|
||||
self.assertIn(('cache-control', cache_control), self.srmock.headers)
|
||||
|
||||
etag = 'fa0d1a60ef6616bb28038515c8ea4cb2'
|
||||
self.assertEqual(etag, resp.etag)
|
||||
self.assertIn(('ETag', etag), self.srmock.headers)
|
||||
self.assertIn(('etag', etag), self.srmock.headers)
|
||||
|
||||
last_modified_http_date = 'Tue, 01 Jan 2013 10:30:30 GMT'
|
||||
self.assertEqual(last_modified_http_date, resp.last_modified)
|
||||
self.assertIn(('Last-Modified', last_modified_http_date),
|
||||
self.assertIn(('last-modified', last_modified_http_date),
|
||||
self.srmock.headers)
|
||||
|
||||
self.assertEqual('3601', resp.retry_after)
|
||||
self.assertIn(('Retry-After', '3601'), self.srmock.headers)
|
||||
self.assertIn(('retry-after', '3601'), self.srmock.headers)
|
||||
|
||||
self.assertEqual('/things/87', resp.location)
|
||||
self.assertIn(('Location', '/things/87'), self.srmock.headers)
|
||||
self.assertIn(('location', '/things/87'), self.srmock.headers)
|
||||
|
||||
self.assertEqual('/things/78', resp.content_location)
|
||||
self.assertIn(('Content-Location', '/things/78'), self.srmock.headers)
|
||||
self.assertIn(('content-location', '/things/78'), self.srmock.headers)
|
||||
|
||||
self.assertEqual('bytes 0-499/10240', resp.content_range)
|
||||
self.assertIn(('Content-Range', 'bytes 0-499/10240'),
|
||||
self.assertIn(('content-range', 'bytes 0-499/10240'),
|
||||
self.srmock.headers)
|
||||
|
||||
# Check for duplicate headers
|
||||
@@ -290,52 +316,55 @@ class TestHeaders(testing.TestBase):
|
||||
self.api.add_route(self.test_route, LocationHeaderUnicodeResource())
|
||||
self.simulate_request(self.test_route)
|
||||
|
||||
location = ('Location', '/%C3%A7runchy/bacon')
|
||||
location = ('location', '/%C3%A7runchy/bacon')
|
||||
self.assertIn(location, self.srmock.headers)
|
||||
|
||||
content_location = ('Content-Location', 'ab%C3%A7')
|
||||
content_location = ('content-location', 'ab%C3%A7')
|
||||
self.assertIn(content_location, self.srmock.headers)
|
||||
|
||||
# Test with the values swapped
|
||||
self.simulate_request(self.test_route, method='HEAD')
|
||||
|
||||
location = ('Location', 'ab%C3%A7')
|
||||
location = ('location', 'ab%C3%A7')
|
||||
self.assertIn(location, self.srmock.headers)
|
||||
|
||||
content_location = ('Content-Location', '/%C3%A7runchy/bacon')
|
||||
content_location = ('content-location', '/%C3%A7runchy/bacon')
|
||||
self.assertIn(content_location, self.srmock.headers)
|
||||
|
||||
def test_response_header_helpers_on_head(self):
|
||||
def test_response_set_header(self):
|
||||
self.resource = HeaderHelpersResource()
|
||||
self.api.add_route(self.test_route, self.resource)
|
||||
self.simulate_request(self.test_route, method="HEAD")
|
||||
|
||||
content_type = 'x-falcon/peregrine'
|
||||
self.assertIn(('Content-Type', content_type), self.srmock.headers)
|
||||
self.assertIn(('Cache-Control', 'no-store'), self.srmock.headers)
|
||||
self.assertIn(('X-Auth-Token', 'toomanysecrets'), self.srmock.headers)
|
||||
for method in ('HEAD', 'POST', 'PUT'):
|
||||
self.simulate_request(self.test_route, method=method)
|
||||
|
||||
self.assertEqual(None, self.resource.resp.location)
|
||||
content_type = 'x-falcon/peregrine'
|
||||
self.assertIn(('content-type', content_type), self.srmock.headers)
|
||||
self.assertIn(('cache-control', 'no-store'), self.srmock.headers)
|
||||
self.assertIn(('x-auth-token', 'toomanysecrets'),
|
||||
self.srmock.headers)
|
||||
|
||||
# Check for duplicate headers
|
||||
hist = defaultdict(lambda: 0)
|
||||
for name, value in self.srmock.headers:
|
||||
hist[name] += 1
|
||||
self.assertEqual(1, hist[name])
|
||||
self.assertEqual(None, self.resource.resp.location)
|
||||
|
||||
# Check for duplicate headers
|
||||
hist = defaultdict(lambda: 0)
|
||||
for name, value in self.srmock.headers:
|
||||
hist[name] += 1
|
||||
self.assertEqual(1, hist[name])
|
||||
|
||||
def test_vary_star(self):
|
||||
self.resource = VaryHeaderResource(['*'])
|
||||
self.api.add_route(self.test_route, self.resource)
|
||||
self.simulate_request(self.test_route)
|
||||
|
||||
self.assertIn(('Vary', '*'), self.srmock.headers)
|
||||
self.assertIn(('vary', '*'), self.srmock.headers)
|
||||
|
||||
def test_vary_header(self):
|
||||
self.resource = VaryHeaderResource(['accept-encoding'])
|
||||
self.api.add_route(self.test_route, self.resource)
|
||||
self.simulate_request(self.test_route)
|
||||
|
||||
self.assertIn(('Vary', 'accept-encoding'), self.srmock.headers)
|
||||
self.assertIn(('vary', 'accept-encoding'), self.srmock.headers)
|
||||
|
||||
def test_vary_headers(self):
|
||||
self.resource = VaryHeaderResource(['accept-encoding', 'x-auth-token'])
|
||||
@@ -343,7 +372,7 @@ class TestHeaders(testing.TestBase):
|
||||
self.simulate_request(self.test_route)
|
||||
|
||||
vary = 'accept-encoding, x-auth-token'
|
||||
self.assertIn(('Vary', vary), self.srmock.headers)
|
||||
self.assertIn(('vary', vary), self.srmock.headers)
|
||||
|
||||
def test_vary_headers_tuple(self):
|
||||
self.resource = VaryHeaderResource(('accept-encoding', 'x-auth-token'))
|
||||
@@ -351,14 +380,14 @@ class TestHeaders(testing.TestBase):
|
||||
self.simulate_request(self.test_route)
|
||||
|
||||
vary = 'accept-encoding, x-auth-token'
|
||||
self.assertIn(('Vary', vary), self.srmock.headers)
|
||||
self.assertIn(('vary', vary), self.srmock.headers)
|
||||
|
||||
def test_no_content_type(self):
|
||||
self.resource = DefaultContentTypeResource()
|
||||
self.api.add_route(self.test_route, self.resource)
|
||||
self.simulate_request(self.test_route)
|
||||
|
||||
self.assertNotIn('Content-Type', self.srmock.headers_dict)
|
||||
self.assertNotIn('content-type', self.srmock.headers_dict)
|
||||
|
||||
def test_custom_content_type(self):
|
||||
content_type = 'application/xml; charset=utf-8'
|
||||
@@ -366,4 +395,4 @@ class TestHeaders(testing.TestBase):
|
||||
self.api.add_route(self.test_route, self.resource)
|
||||
|
||||
self.simulate_request(self.test_route)
|
||||
self.assertIn(('Content-Type', content_type), self.srmock.headers)
|
||||
self.assertIn(('content-type', content_type), self.srmock.headers)
|
||||
|
||||
@@ -96,7 +96,7 @@ class TestHelloWorld(testing.TestBase):
|
||||
body = self.simulate_request(self.test_route)
|
||||
resp = self.resource.resp
|
||||
|
||||
content_length = int(self.srmock.headers_dict['Content-Length'])
|
||||
content_length = int(self.srmock.headers_dict['content-length'])
|
||||
self.assertEqual(content_length, len(self.resource.sample_utf8))
|
||||
|
||||
self.assertEqual(self.srmock.status, self.resource.sample_status)
|
||||
@@ -108,7 +108,7 @@ class TestHelloWorld(testing.TestBase):
|
||||
body = self.simulate_request('/bytes')
|
||||
resp = self.bytes_resource.resp
|
||||
|
||||
content_length = int(self.srmock.headers_dict['Content-Length'])
|
||||
content_length = int(self.srmock.headers_dict['content-length'])
|
||||
self.assertEqual(content_length, len(self.resource.sample_utf8))
|
||||
|
||||
self.assertEqual(self.srmock.status, self.resource.sample_status)
|
||||
@@ -120,7 +120,7 @@ class TestHelloWorld(testing.TestBase):
|
||||
body = self.simulate_request('/data')
|
||||
resp = self.data_resource.resp
|
||||
|
||||
content_length = int(self.srmock.headers_dict['Content-Length'])
|
||||
content_length = int(self.srmock.headers_dict['content-length'])
|
||||
self.assertEqual(content_length, len(self.resource.sample_utf8))
|
||||
|
||||
self.assertEqual(self.srmock.status, self.resource.sample_status)
|
||||
@@ -153,7 +153,7 @@ class TestHelloWorld(testing.TestBase):
|
||||
dest.write(chunk)
|
||||
|
||||
expected_len = self.stream_resource.resp.stream_len
|
||||
content_length = ('Content-Length', str(expected_len))
|
||||
content_length = ('content-length', str(expected_len))
|
||||
self.assertThat(self.srmock.headers, Contains(content_length))
|
||||
self.assertEqual(dest.tell(), expected_len)
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ class TestHttpMethodRouting(testing.TestBase):
|
||||
self.assertEqual(self.srmock.status, falcon.HTTP_405)
|
||||
|
||||
headers = self.srmock.headers
|
||||
allow_header = ('Allow', 'GET, HEAD, PUT, OPTIONS')
|
||||
allow_header = ('allow', 'GET, HEAD, PUT, OPTIONS')
|
||||
|
||||
self.assertThat(headers, Contains(allow_header))
|
||||
|
||||
@@ -180,7 +180,7 @@ class TestHttpMethodRouting(testing.TestBase):
|
||||
self.assertEqual(self.srmock.status, falcon.HTTP_405)
|
||||
|
||||
headers = self.srmock.headers
|
||||
allow_header = ('Allow', 'GET, PUT, OPTIONS')
|
||||
allow_header = ('allow', 'GET, PUT, OPTIONS')
|
||||
|
||||
self.assertThat(headers, Contains(allow_header))
|
||||
|
||||
@@ -189,7 +189,7 @@ class TestHttpMethodRouting(testing.TestBase):
|
||||
self.assertEqual(self.srmock.status, falcon.HTTP_204)
|
||||
|
||||
headers = self.srmock.headers
|
||||
allow_header = ('Allow', 'GET, HEAD, PUT')
|
||||
allow_header = ('allow', 'GET, HEAD, PUT')
|
||||
|
||||
self.assertThat(headers, Contains(allow_header))
|
||||
|
||||
|
||||
@@ -259,7 +259,7 @@ class TestHTTPError(testing.TestBase):
|
||||
self.simulate_request('/401')
|
||||
|
||||
self.assertEqual(self.srmock.status, falcon.HTTP_401)
|
||||
self.assertIn(('WWW-Authenticate', 'Token; UUID'),
|
||||
self.assertIn(('www-authenticate', 'Token; UUID'),
|
||||
self.srmock.headers)
|
||||
|
||||
def test_401_schemaless(self):
|
||||
@@ -267,7 +267,7 @@ class TestHTTPError(testing.TestBase):
|
||||
self.simulate_request('/401')
|
||||
|
||||
self.assertEqual(self.srmock.status, falcon.HTTP_401)
|
||||
self.assertNotIn(('WWW-Authenticate', 'Token'), self.srmock.headers)
|
||||
self.assertNotIn(('www-authenticate', 'Token'), self.srmock.headers)
|
||||
|
||||
def test_404(self):
|
||||
self.api.add_route('/404', NotFoundResource())
|
||||
@@ -282,7 +282,7 @@ class TestHTTPError(testing.TestBase):
|
||||
|
||||
self.assertEqual(self.srmock.status, falcon.HTTP_405)
|
||||
self.assertEqual(body, [])
|
||||
self.assertIn(('Allow', 'PUT'), self.srmock.headers)
|
||||
self.assertIn(('allow', 'PUT'), self.srmock.headers)
|
||||
|
||||
def test_411(self):
|
||||
self.api.add_route('/411', LengthRequiredResource())
|
||||
@@ -300,9 +300,9 @@ class TestHTTPError(testing.TestBase):
|
||||
|
||||
self.assertEqual(self.srmock.status, falcon.HTTP_416)
|
||||
self.assertEqual(body, [])
|
||||
self.assertIn(('Content-Range', 'bytes */123456'), self.srmock.headers)
|
||||
self.assertIn(('Content-Type', 'application/xml'), self.srmock.headers)
|
||||
self.assertNotIn(('Content-Length', '0'), self.srmock.headers)
|
||||
self.assertIn(('content-range', 'bytes */123456'), self.srmock.headers)
|
||||
self.assertIn(('content-type', 'application/xml'), self.srmock.headers)
|
||||
self.assertNotIn(('content-length', '0'), self.srmock.headers)
|
||||
|
||||
def test_416_custom_media_type(self):
|
||||
self.api.add_route('/416', RangeNotSatisfiableResource())
|
||||
@@ -310,9 +310,9 @@ class TestHTTPError(testing.TestBase):
|
||||
|
||||
self.assertEqual(self.srmock.status, falcon.HTTP_416)
|
||||
self.assertEqual(body, [])
|
||||
self.assertIn(('Content-Range', 'bytes */123456'),
|
||||
self.assertIn(('content-range', 'bytes */123456'),
|
||||
self.srmock.headers)
|
||||
self.assertIn(('Content-Type', 'x-falcon/peregrine'),
|
||||
self.assertIn(('content-type', 'x-falcon/peregrine'),
|
||||
self.srmock.headers)
|
||||
|
||||
def test_503(self):
|
||||
@@ -324,7 +324,7 @@ class TestHTTPError(testing.TestBase):
|
||||
|
||||
self.assertEqual(self.srmock.status, falcon.HTTP_503)
|
||||
self.assertEqual(body, [expected_body])
|
||||
self.assertIn(('Retry-After', '60'), self.srmock.headers)
|
||||
self.assertIn(('retry-after', '60'), self.srmock.headers)
|
||||
|
||||
def test_misc(self):
|
||||
self._misc_test(falcon.HTTPBadRequest, falcon.HTTP_400)
|
||||
|
||||
Reference in New Issue
Block a user