Repository: jupyterhub/jupyterhub

Issue:
Base url not respected in 0.9.0b1
Hi! Thanks for using JupyterHub.

If you are reporting an issue with JupyterHub:

- Please use the [GitHub issue](https://github.com/jupyterhub/jupyterhub/issues)
  search feature to check if your issue has been asked already. If it has,
  please add your comments to the existing issue.

- Where applicable, please fill out the details below to help us troubleshoot
  the issue that you are facing. Please be as thorough as you are able to
  provide details on the issue.

**How to reproduce the issue**
Update to 0.9.0b1 with a `JupyterHub.bind_url` set and attempt to reach the hub at https://hostname/base_url/hub

**What you expected to happen**
Access hub home page
**What actually happens**
Redirected to https://hostname/hub/base_url/hub were an understandable 404 is given.

**Share what version of JupyterHub you are using**
0.9.0b1


It is also notable that https://hostname/hub/ does lead to the hub home page.

Seeing the introduction of `JupyterHub.bind_url`, this may be only a documentation issue, is `JupyterHub.base_url` deprecated?


Discussion:
Thanks for the report @chicocvenancio. Yes, we definitely need to improve documentation about bind_url. 

@minrk I'm doing a quick scan of issues, but this one seems like we need to look a bit closer at the behavior or tests to make sure we're catching url combinations correctly now that bind_url is introduced.
IIUC @athornton was having some issues with `base_url` so perhaps he can shed some light on the problem...
What's the exact config? Have you set *both* bind_url and base_url? and if so to what?
@minrk https://github.com/chicocvenancio/paws/blob/dev/paws/values.yaml is the public part of the z2jh config used.

Only `base_url` is set.

Resolving patch:
diff --git a/jupyterhub/app.py b/jupyterhub/app.py
--- a/jupyterhub/app.py
+++ b/jupyterhub/app.py
@@ -315,6 +315,7 @@ def _template_paths_default(self):
         should be accessed by users.
 
         .. deprecated: 0.9
+            Use JupyterHub.bind_url
         """
     ).tag(config=True)
 
@@ -330,26 +331,6 @@ def _template_paths_default(self):
         """
     ).tag(config=True)
 
-    @observe('ip', 'port')
-    def _ip_port_changed(self, change):
-        urlinfo = urlparse(self.bind_url)
-        urlinfo = urlinfo._replace(netloc='%s:%i' % (self.ip, self.port))
-        self.bind_url = urlunparse(urlinfo)
-
-    bind_url = Unicode(
-        "http://127.0.0.1:8000",
-        help="""The public facing URL of the whole JupyterHub application.
-
-        This is the address on which the proxy will bind.
-        Sets protocol, ip, base_url
-        """
-    ).tag(config=True)
-
-    @observe('bind_url')
-    def _bind_url_changed(self, change):
-        urlinfo = urlparse(change.new)
-        self.base_url = urlinfo.path
-
     base_url = URLPrefix('/',
         help="""The base URL of the entire application.
 
@@ -366,6 +347,25 @@ def _default_base_url(self):
         # call validate to ensure leading/trailing slashes
         return JupyterHub.base_url.validate(self, urlparse(self.bind_url).path)
 
+    @observe('ip', 'port', 'base_url')
+    def _url_part_changed(self, change):
+        """propagate deprecated ip/port/base_url config to the bind_url"""
+        urlinfo = urlparse(self.bind_url)
+        urlinfo = urlinfo._replace(netloc='%s:%i' % (self.ip, self.port))
+        urlinfo = urlinfo._replace(path=self.base_url)
+        bind_url = urlunparse(urlinfo)
+        if bind_url != self.bind_url:
+            self.bind_url = bind_url
+
+    bind_url = Unicode(
+        "http://127.0.0.1:8000",
+        help="""The public facing URL of the whole JupyterHub application.
+
+        This is the address on which the proxy will bind.
+        Sets protocol, ip, base_url
+        """
+    ).tag(config=True)
+
     subdomain_host = Unicode('',
         help="""Run single-user servers on subdomains of this host.
 


Test patch:
diff --git a/jupyterhub/tests/test_app.py b/jupyterhub/tests/test_app.py
--- a/jupyterhub/tests/test_app.py
+++ b/jupyterhub/tests/test_app.py
@@ -8,19 +8,22 @@
 from tempfile import NamedTemporaryFile, TemporaryDirectory
 from unittest.mock import patch
 
-from tornado import gen
 import pytest
+from tornado import gen
+from traitlets.config import Config
 
 from .mocking import MockHub
 from .test_api import add_user
 from .. import orm
-from ..app import COOKIE_SECRET_BYTES
+from ..app import COOKIE_SECRET_BYTES, JupyterHub
+
 
 def test_help_all():
     out = check_output([sys.executable, '-m', 'jupyterhub', '--help-all']).decode('utf8', 'replace')
     assert '--ip' in out
     assert '--JupyterHub.ip' in out
 
+
 def test_token_app():
     cmd = [sys.executable, '-m', 'jupyterhub', 'token']
     out = check_output(cmd + ['--help-all']).decode('utf8', 'replace')
@@ -30,6 +33,7 @@ def test_token_app():
         out = check_output(cmd + ['user'], cwd=td).decode('utf8', 'replace').strip()
     assert re.match(r'^[a-z0-9]+$', out)
 
+
 def test_generate_config():
     with NamedTemporaryFile(prefix='jupyterhub_config', suffix='.py') as tf:
         cfg_file = tf.name
@@ -218,3 +222,41 @@ def new_hub():
     assert not user.running
     assert user.spawner.server is None
     assert list(db.query(orm.Server)) == []
+
+
+@pytest.mark.parametrize(
+    'hub_config, expected',
+    [
+        (
+            {'ip': '0.0.0.0'},
+            {'bind_url': 'http://0.0.0.0:8000/'},
+        ),
+        (
+            {'port': 123, 'base_url': '/prefix'},
+            {
+                'bind_url': 'http://:123/prefix/',
+                'base_url': '/prefix/',
+            },
+        ),
+        (
+            {'bind_url': 'http://0.0.0.0:12345/sub'},
+            {'base_url': '/sub/'},
+        ),
+    ]
+)
+def test_url_config(hub_config, expected):
+    # construct the config object
+    cfg = Config()
+    for key, value in hub_config.items():
+        cfg.JupyterHub[key] = value
+
+    # instantiate the Hub and load config
+    app = JupyterHub(config=cfg)
+    # validate config
+    for key, value in hub_config.items():
+        if key not in expected:
+            assert getattr(app, key) == value
+
+    # validate additional properties
+    for key, value in expected.items():
+        assert getattr(app, key) == value