Merge pull request #337 from kgriffs/issues/332

feat(Requests): Add req.host and req.subdomain
This commit is contained in:
Kurt Griffiths
2014-10-13 15:26:50 -05:00
7 changed files with 299 additions and 52 deletions

View File

@@ -55,6 +55,15 @@ class Request(object):
Attributes:
protocol (str): Either 'http' or 'https'.
method (str): HTTP method requested (e.g., GET, POST, etc.)
host (str): Hostname requested by the client
subdomain (str): Leftmost (i.e., most specific) subdomain from the
hostname. If only a single domain name is given, `subdomain`
will be *None*.
Note:
If the hostname in the request is an IP address, the value
for `subdomain` is undefined.
user_agent (str): Value of the User-Agent header, or *None* if the
header is missing.
app (str): Name of the WSGI app (if using WSGI's notion of virtual
@@ -359,11 +368,31 @@ class Request(object):
@property
def uri(self):
if self._cached_uri is None:
env = self.env
protocol = env['wsgi.url_scheme']
# NOTE(kgriffs): According to PEP-3333 we should first
# try to use the Host header if present.
#
# PERF(kgriffs): try..except is faster than .get
try:
host = env['HTTP_HOST']
except KeyError:
host = env['SERVER_NAME']
port = env['SERVER_PORT']
if protocol == 'https':
if port != '443':
host += ':' + port
else:
if port != '80':
host += ':' + port
# PERF: For small numbers of items, '+' is faster
# than ''.join(...). Concatenation is also generally
# faster than formatting.
value = (self.protocol + '://' +
self.get_header('host') +
value = (protocol + '://' +
host +
self.app +
self.path)
@@ -376,6 +405,27 @@ class Request(object):
url = uri
@property
def host(self):
try:
# NOTE(kgriffs): Prefer the host header; the web server
# isn't supposed to mess with it, so it should be what
# the client actually sent.
host_header = self.env['HTTP_HOST']
host, port = uri.parse_host(host_header)
except KeyError:
# PERF(kgriffs): According to PEP-3333, this header
# will always be present.
host = self.env['SERVER_NAME']
return host
@property
def subdomain(self):
# PERF(kgriffs): .partition is slightly faster than .split
subdomain, sep, remainder = self.host.partition('.')
return subdomain if sep else None
@property
def relative_uri(self):
if self._cached_relative_uri is None:

View File

@@ -37,16 +37,6 @@ def normalize_headers(env):
if 'CONTENT_LENGTH' in env:
env['HTTP_CONTENT_LENGTH'] = env['CONTENT_LENGTH']
# Fallback to SERVER_* vars if the Host header isn't specified
if 'HTTP_HOST' not in env:
host = env['SERVER_NAME']
port = env['SERVER_PORT']
if port != '80':
host = ''.join([host, ':', port])
env['HTTP_HOST'] = host
class Body(object):
"""Wrap wsgi.input streams to make them more robust.

View File

@@ -51,7 +51,8 @@ def rand_string(min, max):
for i in range(string_length)])
def create_environ(path='/', query_string='', protocol='HTTP/1.1', port='80',
def create_environ(path='/', query_string='', protocol='HTTP/1.1',
scheme='http', host=DEFAULT_HOST, port=None,
headers=None, app='', body='', method='GET',
wsgierrors=None, file_wrapper=None):
@@ -62,8 +63,13 @@ def create_environ(path='/', query_string='', protocol='HTTP/1.1', port='80',
query_string (str, optional): The query string to simulate, without a
leading '?' (default '')
protocol (str, optional): The HTTP protocol to simulate
(default 'HTTP/1.1')
port (str, optional): The TCP port to simulate (default '80')
(default 'HTTP/1.1'). If set 'HTTP/1.0', the Host header
will not be added to the environment.
scheme (str): URL scheme, either 'http' or 'https' (default 'http')
host(str): Hostname for the request (default 'falconframework.org')
port (str or int, optional): The TCP port to simulate. Defaults to
the standard port used by the given scheme (i.e., 80 for 'http'
and 443 for 'https').
headers (dict or list, optional): Headers as a dict or an
iterable collection of ``(key, value)`` tuples
app (str): Value for the SCRIPT_NAME environ variable, described in
@@ -88,6 +94,12 @@ def create_environ(path='/', query_string='', protocol='HTTP/1.1', port='80',
if six.PY2 and isinstance(path, unicode): # pragma: nocover
path = path.encode('utf-8')
scheme = scheme.lower()
if port is None:
port = '80' if scheme == 'http' else '443'
else:
port = str(port)
env = {
'SERVER_PROTOCOL': protocol,
'SERVER_SOFTWARE': 'gunicorn/0.17.0',
@@ -99,10 +111,10 @@ def create_environ(path='/', query_string='', protocol='HTTP/1.1', port='80',
'REMOTE_PORT': '65133',
'RAW_URI': '/',
'REMOTE_ADDR': '127.0.0.1',
'SERVER_NAME': 'localhost',
'SERVER_NAME': host,
'SERVER_PORT': port,
'wsgi.url_scheme': 'http',
'wsgi.url_scheme': scheme,
'wsgi.input': body,
'wsgi.errors': wsgierrors or sys.stderr,
'wsgi.multithread': False,
@@ -114,7 +126,16 @@ def create_environ(path='/', query_string='', protocol='HTTP/1.1', port='80',
env['wsgi.file_wrapper'] = file_wrapper
if protocol != 'HTTP/1.0':
env['HTTP_HOST'] = DEFAULT_HOST
host_header = host
if scheme == 'https':
if port != '443':
host_header += ':' + port
else:
if port != '80':
host_header += ':' + port
env['HTTP_HOST'] = host_header
content_length = body.seek(0, 2)
body.seek(0)

View File

@@ -311,3 +311,52 @@ def parse_query_string(query_string):
params[k] = v
return params
def parse_host(host, default_port=None):
"""Parse a canonical host:port string into parts.
Parse a host string (which may or may not contain a port) into
parts, taking into account that the string may contain
either a domain name or an IP address. In the latter case,
both IPv4 and IPv6 addresses are supported.
Args:
host (str): Host string to parse, optionally containing a
port number.
default_port (int, optional): Port number to return when
the host string does not contain one (default ``None``).
Returns:
tuple: A parsed (host, port) tuple from the given
host string, with the port converted to an ``int``.
If the string does not specify a port, `default_port` is
used instead.
"""
# NOTE(kgriff): The value from the Host header may
# contain a port, so check that and strip it if
# necessary. This is complicated by the fact that
# a hostname may be specified either as an IP address
# or as a domain name, and in the case of IPv6 there
# may be multiple colons in the string.
if host.startswith('['):
# IPv6 address with a port
pos = host.rfind(']:')
if pos != -1:
return (host[1:pos], int(host[pos + 2:]))
else:
return (host[1:-1], default_port)
pos = host.rfind(':')
if (pos == -1) or (pos != host.find(':')):
# Bare domain name or IP address
return (host, default_port)
# NOTE(kgriffs): At this point we know that there was
# only a single colon, so we should have an IPv4 address
# or a domain name plus a port
name, _, port = host.partition(':')
return (name, int(port))

View File

@@ -187,30 +187,6 @@ class TestHeaders(testing.TestBase):
self.resource.req.get_header, 'X-Not-Found',
required=True)
def test_prefer_host_header(self):
self.simulate_request(self.test_route)
# Make sure we picked up host from HTTP_HOST, not SERVER_NAME
host = self.resource.req.get_header('host')
self.assertEqual(host, testing.DEFAULT_HOST)
def test_host_fallback(self):
# Set protocol to 1.0 so that we won't get a host header
self.simulate_request(self.test_route, protocol='HTTP/1.0')
# Make sure we picked up host from HTTP_HOST, not SERVER_NAME
host = self.resource.req.get_header('host')
self.assertEqual(host, 'localhost')
def test_host_fallback_port8000(self):
# Set protocol to 1.0 so that we won't get a host header
self.simulate_request(self.test_route, protocol='HTTP/1.0',
port='8000')
# Make sure we picked up host from HTTP_HOST, not SERVER_NAME
host = self.resource.req.get_header('host')
self.assertEqual(host, 'localhost:8000')
def test_no_body_on_100(self):
self.resource = StatusTestResource(falcon.HTTP_100)
self.api.add_route('/1xx', self.resource)

View File

@@ -11,7 +11,6 @@ class TestReqVars(testing.TestBase):
self.qs = 'marker=deadbeef&limit=10'
self.headers = {
'Host': 'falcon.example.com',
'Content-Type': 'text/plain',
'Content-Length': '4829',
'Authorization': ''
@@ -20,11 +19,10 @@ class TestReqVars(testing.TestBase):
self.app = '/test'
self.path = '/hello'
self.relative_uri = self.path + '?' + self.qs
self.uri = 'http://falcon.example.com' + self.app + self.relative_uri
self.uri_noqs = 'http://falcon.example.com' + self.app + self.path
self.req = Request(testing.create_environ(
app=self.app,
port=8080,
path='/hello',
query_string=self.qs,
headers=self.headers))
@@ -45,6 +43,53 @@ class TestReqVars(testing.TestBase):
def test_empty(self):
self.assertIs(self.req.auth, None)
def test_host(self):
self.assertEqual(self.req.host, testing.DEFAULT_HOST)
def test_subdomain(self):
req = Request(testing.create_environ(
host='com',
path='/hello',
headers=self.headers))
self.assertIs(req.subdomain, None)
req = Request(testing.create_environ(
host='example.com',
path='/hello',
headers=self.headers))
self.assertEqual(req.subdomain, 'example')
req = Request(testing.create_environ(
host='highwire.example.com',
path='/hello',
headers=self.headers))
self.assertEqual(req.subdomain, 'highwire')
req = Request(testing.create_environ(
host='lb01.dfw01.example.com',
port=8080,
path='/hello',
headers=self.headers))
self.assertEqual(req.subdomain, 'lb01')
# NOTE(kgriffs): Behavior for IP addresses is undefined,
# so just make sure it doesn't blow up.
req = Request(testing.create_environ(
host='127.0.0.1',
path='/hello',
headers=self.headers))
self.assertEqual(type(req.subdomain), str)
# NOTE(kgriffs): Test fallback to SERVER_NAME by using
# HTTP 1.0, which will cause .create_environ to not set
# HTTP_HOST.
req = Request(testing.create_environ(
protocol='HTTP/1.0',
host='example.com',
path='/hello',
headers=self.headers))
self.assertEqual(req.subdomain, 'example')
def test_reconstruct_url(self):
req = self.req
@@ -54,18 +99,90 @@ class TestReqVars(testing.TestBase):
path = req.path
query_string = req.query_string
actual_url = ''.join([scheme, '://', host, app, path,
'?', query_string])
self.assertEqual(actual_url, self.uri)
expected_uri = ''.join([scheme, '://', host, app, path,
'?', query_string])
self.assertEqual(expected_uri, req.uri)
def test_uri(self):
self.assertEqual(self.req.url, self.uri)
uri = ('http://' + testing.DEFAULT_HOST + ':8080' +
self.app + self.relative_uri)
self.assertEqual(self.req.url, uri)
# NOTE(kgriffs): Call twice to check caching works
self.assertEqual(self.req.uri, self.uri)
self.assertEqual(self.req.uri, self.uri)
self.assertEqual(self.req.uri, uri)
self.assertEqual(self.req.uri, uri)
self.assertEqual(self.req_noqs.uri, self.uri_noqs)
uri_noqs = ('http://' + testing.DEFAULT_HOST + self.app + self.path)
self.assertEqual(self.req_noqs.uri, uri_noqs)
def test_uri_http_1_0(self):
# =======================================================
# HTTP, 80
# =======================================================
req = Request(testing.create_environ(
protocol='HTTP/1.0',
app=self.app,
port=80,
path='/hello',
query_string=self.qs,
headers=self.headers))
uri = ('http://' + testing.DEFAULT_HOST +
self.app + self.relative_uri)
self.assertEqual(req.uri, uri)
# =======================================================
# HTTP, 80
# =======================================================
req = Request(testing.create_environ(
protocol='HTTP/1.0',
app=self.app,
port=8080,
path='/hello',
query_string=self.qs,
headers=self.headers))
uri = ('http://' + testing.DEFAULT_HOST + ':8080' +
self.app + self.relative_uri)
self.assertEqual(req.uri, uri)
# =======================================================
# HTTP, 80
# =======================================================
req = Request(testing.create_environ(
protocol='HTTP/1.0',
scheme='https',
app=self.app,
port=443,
path='/hello',
query_string=self.qs,
headers=self.headers))
uri = ('https://' + testing.DEFAULT_HOST +
self.app + self.relative_uri)
self.assertEqual(req.uri, uri)
# =======================================================
# HTTP, 80
# =======================================================
req = Request(testing.create_environ(
protocol='HTTP/1.0',
scheme='https',
app=self.app,
port=22,
path='/hello',
query_string=self.qs,
headers=self.headers))
uri = ('https://' + testing.DEFAULT_HOST + ':22' +
self.app + self.relative_uri)
self.assertEqual(req.uri, uri)
def test_relative_uri(self):
self.assertEqual(self.req.relative_uri, self.app + self.relative_uri)

View File

@@ -185,6 +185,50 @@ class TestFalconUtils(testtools.TestCase):
actual = uri.decode(case)
self.assertEqual(expect, actual)
def test_parse_host(self):
self.assertEqual(uri.parse_host('::1'), ('::1', None))
self.assertEqual(uri.parse_host('2001:ODB8:AC10:FE01::'),
('2001:ODB8:AC10:FE01::', None))
self.assertEqual(
uri.parse_host('2001:ODB8:AC10:FE01::', default_port=80),
('2001:ODB8:AC10:FE01::', 80))
ipv6_addr = '2001:4801:1221:101:1c10::f5:116'
self.assertEqual(uri.parse_host(ipv6_addr), (ipv6_addr, None))
self.assertEqual(uri.parse_host('[' + ipv6_addr + ']'),
(ipv6_addr, None))
self.assertEqual(uri.parse_host('[' + ipv6_addr + ']:28080'),
(ipv6_addr, 28080))
self.assertEqual(uri.parse_host('[' + ipv6_addr + ']:8080'),
(ipv6_addr, 8080))
self.assertEqual(uri.parse_host('[' + ipv6_addr + ']:123'),
(ipv6_addr, 123))
self.assertEqual(uri.parse_host('[' + ipv6_addr + ']:42'),
(ipv6_addr, 42))
self.assertEqual(uri.parse_host('173.203.44.122'),
('173.203.44.122', None))
self.assertEqual(uri.parse_host('173.203.44.122', default_port=80),
('173.203.44.122', 80))
self.assertEqual(uri.parse_host('173.203.44.122:27070'),
('173.203.44.122', 27070))
self.assertEqual(uri.parse_host('173.203.44.122:123'),
('173.203.44.122', 123))
self.assertEqual(uri.parse_host('173.203.44.122:42'),
('173.203.44.122', 42))
self.assertEqual(uri.parse_host('example.com'),
('example.com', None))
self.assertEqual(uri.parse_host('example.com', default_port=443),
('example.com', 443))
self.assertEqual(uri.parse_host('falcon.example.com'),
('falcon.example.com', None))
self.assertEqual(uri.parse_host('falcon.example.com:9876'),
('falcon.example.com', 9876))
self.assertEqual(uri.parse_host('falcon.example.com:42'),
('falcon.example.com', 42))
class TestFalconTesting(falcon.testing.TestBase):
"""Catch some uncommon branches not covered elsewhere."""