Repository: falconry/falcon

Issue:
Add req.host
May also want a req.subdomain helper



Discussion:
:+1: Awesome!

I'll take this


Resolving patch:
diff --git a/falcon/request.py b/falcon/request.py
--- a/falcon/request.py
+++ b/falcon/request.py
@@ -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 @@ def protocol(self):
     @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 @@ def uri(self):
 
     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:
diff --git a/falcon/request_helpers.py b/falcon/request_helpers.py
--- a/falcon/request_helpers.py
+++ b/falcon/request_helpers.py
@@ -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.
diff --git a/falcon/util/uri.py b/falcon/util/uri.py
--- a/falcon/util/uri.py
+++ b/falcon/util/uri.py
@@ -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))


Test patch:
diff --git a/falcon/testing/helpers.py b/falcon/testing/helpers.py
--- a/falcon/testing/helpers.py
+++ b/falcon/testing/helpers.py
@@ -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)
diff --git a/tests/test_headers.py b/tests/test_headers.py
--- a/tests/test_headers.py
+++ b/tests/test_headers.py
@@ -187,30 +187,6 @@ def test_required_header(self):
                           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)
diff --git a/tests/test_req_vars.py b/tests/test_req_vars.py
--- a/tests/test_req_vars.py
+++ b/tests/test_req_vars.py
@@ -11,7 +11,6 @@ def before(self):
         self.qs = 'marker=deadbeef&limit=10'
 
         self.headers = {
-            'Host': 'falcon.example.com',
             'Content-Type': 'text/plain',
             'Content-Length': '4829',
             'Authorization': ''
@@ -20,11 +19,10 @@ def before(self):
         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 @@ def test_missing_qs(self):
     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 @@ def test_reconstruct_url(self):
         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)
+
+        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(self.req_noqs.uri, self.uri_noqs)
+        self.assertEqual(req.uri, uri)
 
     def test_relative_uri(self):
         self.assertEqual(self.req.relative_uri, self.app + self.relative_uri)
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -185,6 +185,50 @@ def test_prop_uri_decode_models_stdlib_unquote_plus(self):
             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."""