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:
kgriffs
2014-01-02 12:45:58 -06:00
parent f4fcb5a9b0
commit 9a22f69e73
8 changed files with 116 additions and 74 deletions

View File

@@ -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())

View File

@@ -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)

View File

@@ -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)

View File

@@ -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))

View File

@@ -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)

View File

@@ -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)

View File

@@ -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))

View File

@@ -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)