Skip to content

Commit 9b4b037

Browse files
Merge pull request #197 from minrk/trailing-slash-route
make sure that `/prefix` and `/prefix/` are handled the same
2 parents 269a9bf + d2b9152 commit 9b4b037

File tree

4 files changed

+85
-13
lines changed

4 files changed

+85
-13
lines changed

jupyterhub_traefik_proxy/traefik_utils.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,46 @@ def validate(self, obj, value):
2222

2323

2424
def generate_rule(routespec):
25+
"""Generate a traefik routing rule for a jupyterhub routespec
26+
27+
28+
- if a routespec doesn't start with a `/`, the first part is a hostname.
29+
- routespecs always end with `/`
30+
- routespecs are a path _prefix_, and should match anything under them
31+
- the root of the route without the trailing slash should match the rule,
32+
e.g. the routespec `/prefix/` should match `/prefix` and `/prefix/tree`
33+
34+
Traefik rule documentation: https://doc.traefik.io/traefik/routing/routers/#rule
35+
"""
36+
37+
# validate assumption that routespecs always end with '/'
38+
if not routespec.endswith("/"):
39+
raise ValueError("routespec must end with /")
2540
routespec = unquote(routespec)
41+
42+
# traefik won't match /proxy/path to /proxy/path/
43+
# so strip trailing slash for consistent behavior
2644
if routespec.startswith("/"):
2745
# Path-based route, e.g. /proxy/path/
28-
rule = f"PathPrefix(`{routespec}`)"
46+
host = ""
47+
path = routespec
2948
else:
3049
# Host-based routing, e.g. host.tld/proxy/path/
31-
host, path_prefix = routespec.split("/", 1)
32-
rule = f"Host(`{host}`) && PathPrefix(`/{path_prefix}`)"
50+
host, slash, path = routespec.partition("/")
51+
path = slash + path
52+
53+
path_no_slash = path.rstrip("/")
54+
55+
path_rule = f"PathPrefix(`{path}`)"
56+
if path_no_slash:
57+
# include exact Path('/prefix') so that both /prefix/ and /prefix
58+
# are served correctly
59+
path_rule = f"( {path_rule} || Path(`{path_no_slash}`) )"
60+
61+
if host:
62+
rule = f"Host(`{host}`) && {path_rule}"
63+
else:
64+
rule = path_rule
3365
return rule
3466

3567

tests/test_proxy.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,27 @@ async def _wait_for_deletion():
336336
await test_route_exist(spec, extra_backends[i])
337337

338338

339+
async def test_trailing_slash(proxy, launch_backends):
340+
proxy_url = proxy.public_url.rstrip("/")
341+
routespec = "/path/prefix/"
342+
default_backend, target_backend = await launch_backends(2)
343+
target_port = urlparse(target_backend).port
344+
default_port = urlparse(default_backend).port
345+
346+
await proxy.add_route("/", default_backend, {})
347+
await proxy.add_route(routespec, target_backend, {})
348+
349+
port = await utils.get_responding_backend_port(proxy_url, "/path/prefix/")
350+
assert port == target_port
351+
port = await utils.get_responding_backend_port(proxy_url, "/path/prefix")
352+
assert port == target_port
353+
port = await utils.get_responding_backend_port(proxy_url, "/path/prefixnoslash")
354+
assert port == default_port
355+
356+
for route in ["/", routespec]:
357+
await proxy.delete_route(route)
358+
359+
339360
async def test_get_all_routes(proxy, launch_backends):
340361
# initial state: no routes
341362
routes = await proxy.get_all_routes()

tests/test_traefik_utils.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,20 @@ def test_atomic_writing_recovery(tmpdir):
7676

7777
# didn't leave any residue
7878
assert tmpdir.listdir() == [testfile]
79+
80+
81+
@pytest.mark.parametrize(
82+
"routespec, expected_rule",
83+
[
84+
("/", "PathPrefix(`/`)"),
85+
("/path/prefix/", "( PathPrefix(`/path/prefix/`) || Path(`/path/prefix`) )"),
86+
("host/", "Host(`host`) && PathPrefix(`/`)"),
87+
(
88+
"host/path/prefix/",
89+
"Host(`host`) && ( PathPrefix(`/path/prefix/`) || Path(`/path/prefix`) )",
90+
),
91+
],
92+
)
93+
def test_generate_rule(routespec, expected_rule):
94+
rule = traefik_utils.generate_rule(routespec)
95+
assert rule == expected_rule

tests/utils.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -58,18 +58,20 @@ async def check_host_up_http(url):
5858
async def get_responding_backend_port(traefik_url, path):
5959
"""Check if traefik followed the configuration and routed the
6060
request to the right backend"""
61-
if not path.endswith("/"):
62-
path += "/"
61+
62+
headers = {}
6363

6464
if not path.startswith("/"):
65-
req = HTTPRequest(
66-
traefik_url + "".join("/" + path.split("/", 1)[1]),
67-
method="GET",
68-
headers={"Host": path.split("/")[0]},
69-
validate_cert=False,
70-
)
71-
else:
72-
req = HTTPRequest(traefik_url + path, validate_cert=False)
65+
host, slash, path = path.partition("/")
66+
path = slash + path
67+
headers["Host"] = host
68+
69+
req = HTTPRequest(
70+
traefik_url + path,
71+
headers=headers,
72+
validate_cert=False,
73+
follow_redirects=True,
74+
)
7375

7476
try:
7577
resp = await AsyncHTTPClient().fetch(req)

0 commit comments

Comments
 (0)