Skip to content

Commit 1ba1daa

Browse files
committed
fix: ConnectionManager handles trailing slash in bash_url
1 parent c5848e8 commit 1ba1daa

3 files changed

Lines changed: 42 additions & 15 deletions

File tree

src/keycloak/connection.py

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,6 @@
2424

2525
from __future__ import annotations
2626

27-
try:
28-
from urllib.parse import urljoin
29-
except ImportError: # pragma: no cover
30-
from urlparse import urljoin # pyright: ignore[reportMissingImports]
31-
3227
from typing import Any
3328

3429
import httpx
@@ -167,6 +162,13 @@ def base_url(self) -> str | None:
167162
def base_url(self, value: str | None) -> None:
168163
self._base_url = value
169164

165+
def _build_url(self, path: str) -> str:
166+
"""Join the base_url and path, and handle trailing slashes."""
167+
if not self.base_url or path.startswith(("http://", "https://")):
168+
return path
169+
170+
return f"{self.base_url.rstrip('/')}/{path.lstrip('/')}"
171+
170172
@property
171173
def timeout(self) -> int | None:
172174
"""
@@ -334,7 +336,7 @@ def raw_get(self, path: str, **kwargs: Any) -> Response: # noqa: ANN401
334336
raise AttributeError(msg)
335337
try:
336338
return self._s.get(
337-
urljoin(self.base_url, path),
339+
self._build_url(path),
338340
params=kwargs,
339341
headers=self.headers,
340342
timeout=self.timeout,
@@ -364,7 +366,7 @@ def raw_post(self, path: str, data: dict | str | MultipartEncoder, **kwargs: Any
364366
raise AttributeError(msg)
365367
try:
366368
return self._s.post(
367-
urljoin(self.base_url, path),
369+
self._build_url(path),
368370
params=kwargs,
369371
data=data,
370372
headers=self.headers,
@@ -396,7 +398,7 @@ def raw_put(self, path: str, data: dict | str | MultipartEncoder, **kwargs: Any)
396398

397399
try:
398400
return self._s.put(
399-
urljoin(self.base_url, path),
401+
self._build_url(path),
400402
params=kwargs,
401403
data=data,
402404
headers=self.headers,
@@ -428,7 +430,7 @@ def raw_delete(self, path: str, data: dict | None = None, **kwargs: Any) -> Resp
428430

429431
try:
430432
return self._s.delete(
431-
urljoin(self.base_url, path),
433+
self._build_url(path),
432434
params=kwargs,
433435
data=data or {},
434436
headers=self.headers,
@@ -458,7 +460,7 @@ async def a_raw_get(self, path: str, **kwargs: Any) -> AsyncResponse: # noqa: A
458460

459461
try:
460462
return await self.async_s.get(
461-
urljoin(self.base_url, path),
463+
self._build_url(path),
462464
params=self._filter_query_params(kwargs),
463465
headers=self.headers,
464466
timeout=self.timeout,
@@ -493,7 +495,7 @@ async def a_raw_post(
493495
try:
494496
return await self.async_s.request(
495497
method="POST",
496-
url=urljoin(self.base_url, path),
498+
url=self._build_url(path),
497499
params=self._filter_query_params(kwargs),
498500
**self._prepare_httpx_request_content(data),
499501
headers=self.headers,
@@ -528,7 +530,7 @@ async def a_raw_put(
528530

529531
try:
530532
return await self.async_s.put(
531-
urljoin(self.base_url, path),
533+
self._build_url(path),
532534
params=self._filter_query_params(kwargs),
533535
**self._prepare_httpx_request_content(data),
534536
headers=self.headers,
@@ -564,7 +566,7 @@ async def a_raw_delete(
564566
try:
565567
return await self.async_s.request(
566568
method="DELETE",
567-
url=urljoin(self.base_url, path),
569+
url=self._build_url(path),
568570
**self._prepare_httpx_request_content(data or {}),
569571
params=self._filter_query_params(kwargs),
570572
headers=self.headers,

src/keycloak/keycloak_admin.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2386,7 +2386,7 @@ def get_group_by_path(self, path: str) -> dict:
23862386
:return: Keycloak server response (GroupRepresentation)
23872387
:rtype: dict
23882388
"""
2389-
params_path = {"realm-name": self.connection.realm_name, "path": path}
2389+
params_path = {"realm-name": self.connection.realm_name, "path": path.lstrip("/")}
23902390
data_raw = self.connection.raw_get(
23912391
urls_patterns.URL_ADMIN_GROUP_BY_PATH.format(**params_path),
23922392
)
@@ -9276,7 +9276,7 @@ async def a_get_group_by_path(self, path: str) -> dict:
92769276
:return: Keycloak server response (GroupRepresentation)
92779277
:rtype: dict
92789278
"""
9279-
params_path = {"realm-name": self.connection.realm_name, "path": path}
9279+
params_path = {"realm-name": self.connection.realm_name, "path": path.lstrip("/")}
92809280
data_raw = await self.connection.a_raw_get(
92819281
urls_patterns.URL_ADMIN_GROUP_BY_PATH.format(**params_path),
92829282
)

tests/test_connection.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,28 @@ def test_counter_part() -> None:
9595
continue
9696

9797
assert async_method[2:] in sync_methods
98+
99+
100+
def test_build_url() -> None:
101+
"""Test URL building and sub-path preservation."""
102+
# Scenario 1: Base URL WITHOUT a trailing slash
103+
cm = ConnectionManager(base_url="http://test.test/auth")
104+
105+
assert cm._build_url("realms/master") == "http://test.test/auth/realms/master"
106+
assert cm._build_url("/realms/master") == "http://test.test/auth/realms/master"
107+
108+
# Scenario 2: Base URL WITH a trailing slash
109+
cm_slashed = ConnectionManager(base_url="http://test.test/auth/")
110+
111+
assert cm_slashed._build_url("realms/master") == "http://test.test/auth/realms/master"
112+
assert cm_slashed._build_url("/realms/master") == "http://test.test/auth/realms/master"
113+
114+
# Scenario 3: Path is already an absolute URL
115+
assert cm._build_url("http://absolute.test/realms") == "http://absolute.test/realms"
116+
assert cm._build_url("https://absolute.test/realms") == "https://absolute.test/realms"
117+
118+
# Scenario 4: Empty base URL
119+
cm_empty = ConnectionManager(base_url="")
120+
121+
assert cm_empty._build_url("realms/master") == "realms/master"
122+
assert cm_empty._build_url("/realms/master") == "/realms/master"

0 commit comments

Comments
 (0)