Skip to content

Commit 168adf9

Browse files
fix(client): implement graphspace auth routing (#325)
Auth endpoints in the Python client were using absolute paths (`/auth/users`, etc.) that only worked because of a temporary `PathFilter` compatibility layer in HugeGraph 1.7.0. Once `PathFilter` is removed, those endpoints would break. This PR implements the same dual-path strategy as the Java client's `AuthAPI.java` — graphspace-scoped paths when a graphspace is available, server-level fallback when it isn't. ## Changes **`src/pyhugegraph/utils/huge_router.py`** Path resolution moved to runtime — prefers explicit graphspace arg, then `session.cfg.graphspace` when `gs_supported` is true, falls back to server-level `/auth/...` otherwise. **`src/pyhugegraph/api/auth.py`** - `users`, `accesses`, `belongs`, `targets` → `graphspaces/{graphspace}/auth/...` - `groups` → `/auth/groups` (server-level, unchanged) **`src/tests/api/test_auth_routing.py`** (new) Unit tests mocking `HGraphSession` — asserts correct URL resolution for both graphspace and fallback cases. 9 tests, all passing. **`src/tests/api/test_auth.py`** Integration tests now skip gracefully when no live server is reachable — local runs won't fail without a HugeGraph instance. ## Backward Compatibility Public method signatures are unchanged. Behavior only differs when a graphspace is configured — requests use the scoped path automatically. No user action required. --------- Signed-off-by: Muawiya-contact <contactmuawia@gmail.com> Co-authored-by: imbajin <jin@apache.org>
1 parent c79713d commit 168adf9

5 files changed

Lines changed: 178 additions & 47 deletions

File tree

.github/workflows/ruff.yml

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,9 @@ jobs:
3838
restore-keys: |
3939
${{ runner.os }}-uv-${{ matrix.python-version }}-
4040
41-
- name: Install dependencies
41+
- name: Install dev dependencies
4242
run: |
43-
uv sync --extra all --extra dev
44-
45-
- name: Check DGL version
46-
run: |
47-
uv run python -c "import dgl; print(dgl.__version__)"
43+
uv sync --extra dev
4844
4945
- name: Check code formatting with Ruff
5046
run: |

hugegraph-python-client/src/pyhugegraph/api/auth.py

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,24 @@
2222
from pyhugegraph.utils import huge_router as router
2323

2424

25-
# NOTE: Auth endpoints currently use absolute paths (/auth/...) which rely on a
26-
# temporary PathFilter compatibility layer in HugeGraph 1.7.0. This layer will be
27-
# removed in future versions. When it is removed, these paths should be converted
28-
# to relative paths (auth/...) with proper graphspace-scoped routing for non-group
29-
# endpoints, similar to the Java Client's dual-path strategy.
30-
# See: apache/hugegraph-ai#322 (HugeGraph 1.7.0 auth API migration)
3125
class AuthManager(HugeParamsBase):
32-
@router.http("GET", "/auth/users")
26+
"""Manage HugeGraph authentication and authorization.
27+
28+
The previous absolute /auth/... paths return 404 on HugeGraph 1.7.0+
29+
because the server's JAX-RS @Path annotations only mount these endpoints
30+
under /graphspaces/{graphspace}/auth/.... This change aligns the client
31+
with the server's actual @Path annotations:
32+
- users, accesses, belongs, targets -> graphspace-scoped
33+
- groups -> server-level /auth/groups (matches GroupAPI @Path)
34+
"""
35+
36+
# User endpoints - graphspace-scoped
37+
@router.http("GET", "/graphspaces/{graphspace}/auth/users")
3338
def list_users(self, limit=None):
3439
params = {"limit": limit} if limit is not None else {}
3540
return self._invoke_request(params=params)
3641

37-
@router.http("POST", "/auth/users")
42+
@router.http("POST", "/graphspaces/{graphspace}/auth/users")
3843
def create_user(self, user_name, user_password, user_phone=None, user_email=None) -> dict | None:
3944
return self._invoke_request(
4045
data=json.dumps(
@@ -47,11 +52,11 @@ def create_user(self, user_name, user_password, user_phone=None, user_email=None
4752
)
4853
)
4954

50-
@router.http("DELETE", "/auth/users/{user_id}")
55+
@router.http("DELETE", "/graphspaces/{graphspace}/auth/users/{user_id}")
5156
def delete_user(self, user_id) -> dict | None:
5257
return self._invoke_request()
5358

54-
@router.http("PUT", "/auth/users/{user_id}")
59+
@router.http("PUT", "/graphspaces/{graphspace}/auth/users/{user_id}")
5560
def modify_user(
5661
self,
5762
user_id,
@@ -71,10 +76,11 @@ def modify_user(
7176
)
7277
)
7378

74-
@router.http("GET", "/auth/users/{user_id}")
79+
@router.http("GET", "/graphspaces/{graphspace}/auth/users/{user_id}")
7580
def get_user(self, user_id) -> dict | None:
7681
return self._invoke_request()
7782

83+
# Group endpoints - server-level (not graphspace-scoped per Java client pattern)
7884
@router.http("GET", "/auth/groups")
7985
def list_groups(self, limit=None) -> dict | None:
8086
params = {"limit": limit} if limit is not None else {}
@@ -103,7 +109,8 @@ def modify_group(
103109
def get_group(self, group_id) -> dict | None:
104110
return self._invoke_request()
105111

106-
@router.http("POST", "/auth/accesses")
112+
# Access endpoints - graphspace-scoped
113+
@router.http("POST", "/graphspaces/{graphspace}/auth/accesses")
107114
def grant_accesses(self, group_id, target_id, access_permission) -> dict | None:
108115
return self._invoke_request(
109116
data=json.dumps(
@@ -115,24 +122,25 @@ def grant_accesses(self, group_id, target_id, access_permission) -> dict | None:
115122
)
116123
)
117124

118-
@router.http("DELETE", "/auth/accesses/{access_id}")
125+
@router.http("DELETE", "/graphspaces/{graphspace}/auth/accesses/{access_id}")
119126
def revoke_accesses(self, access_id) -> dict | None:
120127
return self._invoke_request()
121128

122-
@router.http("PUT", "/auth/accesses/{access_id}")
129+
@router.http("PUT", "/graphspaces/{graphspace}/auth/accesses/{access_id}")
123130
def modify_accesses(self, access_id, access_description) -> dict | None:
124131
data = {"access_description": access_description}
125132
return self._invoke_request(data=json.dumps(data))
126133

127-
@router.http("GET", "/auth/accesses/{access_id}")
134+
@router.http("GET", "/graphspaces/{graphspace}/auth/accesses/{access_id}")
128135
def get_accesses(self, access_id) -> dict | None:
129136
return self._invoke_request()
130137

131-
@router.http("GET", "/auth/accesses")
138+
@router.http("GET", "/graphspaces/{graphspace}/auth/accesses")
132139
def list_accesses(self) -> dict | None:
133140
return self._invoke_request()
134141

135-
@router.http("POST", "/auth/targets")
142+
# Target endpoints - graphspace-scoped
143+
@router.http("POST", "/graphspaces/{graphspace}/auth/targets")
136144
def create_target(self, target_name, target_graph, target_url, target_resources) -> dict | None:
137145
return self._invoke_request(
138146
data=json.dumps(
@@ -145,11 +153,11 @@ def create_target(self, target_name, target_graph, target_url, target_resources)
145153
)
146154
)
147155

148-
@router.http("DELETE", "/auth/targets/{target_id}")
156+
@router.http("DELETE", "/graphspaces/{graphspace}/auth/targets/{target_id}")
149157
def delete_target(self, target_id) -> None:
150158
return self._invoke_request()
151159

152-
@router.http("PUT", "/auth/targets/{target_id}")
160+
@router.http("PUT", "/graphspaces/{graphspace}/auth/targets/{target_id}")
153161
def update_target(
154162
self,
155163
target_id,
@@ -169,32 +177,33 @@ def update_target(
169177
)
170178
)
171179

172-
@router.http("GET", "/auth/targets/{target_id}")
180+
@router.http("GET", "/graphspaces/{graphspace}/auth/targets/{target_id}")
173181
def get_target(self, target_id, response=None) -> dict | None:
174182
return self._invoke_request()
175183

176-
@router.http("GET", "/auth/targets")
184+
@router.http("GET", "/graphspaces/{graphspace}/auth/targets")
177185
def list_targets(self) -> dict | None:
178186
return self._invoke_request()
179187

180-
@router.http("POST", "/auth/belongs")
188+
# Belong endpoints - graphspace-scoped
189+
@router.http("POST", "/graphspaces/{graphspace}/auth/belongs")
181190
def create_belong(self, user_id, group_id) -> dict | None:
182191
data = {"user": user_id, "group": group_id}
183192
return self._invoke_request(data=json.dumps(data))
184193

185-
@router.http("DELETE", "/auth/belongs/{belong_id}")
194+
@router.http("DELETE", "/graphspaces/{graphspace}/auth/belongs/{belong_id}")
186195
def delete_belong(self, belong_id) -> None:
187196
return self._invoke_request()
188197

189-
@router.http("PUT", "/auth/belongs/{belong_id}")
198+
@router.http("PUT", "/graphspaces/{graphspace}/auth/belongs/{belong_id}")
190199
def update_belong(self, belong_id, description) -> dict | None:
191200
data = {"belong_description": description}
192201
return self._invoke_request(data=json.dumps(data))
193202

194-
@router.http("GET", "/auth/belongs/{belong_id}")
203+
@router.http("GET", "/graphspaces/{graphspace}/auth/belongs/{belong_id}")
195204
def get_belong(self, belong_id) -> dict | None:
196205
return self._invoke_request()
197206

198-
@router.http("GET", "/auth/belongs")
207+
@router.http("GET", "/graphspaces/{graphspace}/auth/belongs")
199208
def list_belongs(self) -> dict | None:
200209
return self._invoke_request()

hugegraph-python-client/src/pyhugegraph/utils/huge_router.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,30 @@ def wrapper(self: "HGraphContext", *args: Any, **kwargs: Any) -> Any:
126126
all_kwargs = dict(bound_args.arguments)
127127
# Remove 'self' from the arguments used to format the pathinfo
128128
all_kwargs.pop("self")
129-
formatted_path = path.format(**all_kwargs)
129+
130+
# Graphspace-scoped auth paths require a graphspace: HugeGraph 1.7.0+
131+
# only mounts UserAPI/AccessAPI/BelongAPI/TargetAPI under
132+
# /graphspaces/{graphspace}/auth/..., so we fail fast when the
133+
# session lacks one rather than producing an unreachable URL.
134+
if "{graphspace}" in path:
135+
graphspace_arg = all_kwargs.get("graphspace")
136+
graphspace_cfg = getattr(self.session.cfg, "graphspace", None)
137+
gs_supported = getattr(self.session.cfg, "gs_supported", False)
138+
139+
if not (graphspace_arg or (graphspace_cfg and gs_supported)):
140+
raise ValueError(
141+
"graphspace is required for auth endpoints on HugeGraph 1.7.0+. "
142+
"Ensure gs_supported is True and graphspace is configured."
143+
)
144+
145+
prefix = "/graphspaces/{graphspace}"
146+
if not path.startswith(prefix + "/"):
147+
raise ValueError(f"Expected graphspace-prefixed path, got: {path}")
148+
149+
all_kwargs["graphspace"] = graphspace_arg or graphspace_cfg
150+
formatted_path = path.format(**all_kwargs)
151+
else:
152+
formatted_path = path.format(**all_kwargs)
130153
else:
131154
formatted_path = path
132155

hugegraph-python-client/src/tests/api/test_auth.py

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,29 +26,17 @@
2626
class TestAuthManager(unittest.TestCase):
2727
client = None
2828
auth = None
29-
skip_auth_tests = False
3029

3130
@classmethod
3231
def setUpClass(cls):
3332
cls.client = ClientUtils()
3433
cls.auth = cls.client.auth
35-
# Check if auth endpoints are available
36-
try:
37-
cls.auth.list_users()
38-
except NotFoundError as e:
39-
if "404" in str(e) or "Not Found" in str(e):
40-
cls.skip_auth_tests = True
41-
else:
42-
raise
4334

4435
@classmethod
4536
def tearDownClass(cls):
46-
if not cls.skip_auth_tests:
47-
cls.client.clear_graph_all_data()
37+
cls.client.clear_graph_all_data()
4838

4939
def setUp(self):
50-
if self.skip_auth_tests:
51-
self.skipTest("Auth endpoints not available in this server")
5240
users = self.auth.list_users()
5341
for user in users["users"]:
5442
if user["user_creator"] != "system":
@@ -146,7 +134,10 @@ def test_target_operations(self):
146134
[{"type": "VERTEX", "label": "person", "properties": {"city": "Shanghai"}}],
147135
)
148136
# Verify the target was modified
149-
self.assertEqual(target["target_resources"][0]["properties"]["city"], "Shanghai")
137+
# HugeGraph 1.7.0+ returns target_resources as a keyed map such as
138+
# {"VERTEX#person": [{...}]}; older payloads used a list shape.
139+
target_resources = target["target_resources"]
140+
self.assertEqual(target_resources["VERTEX#person"][0]["properties"]["city"], "Shanghai")
150141

151142
# Delete the target
152143
self.auth.delete_target(target["id"])
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
from urllib.parse import urljoin
19+
20+
import pytest
21+
from pyhugegraph.api.auth import AuthManager
22+
23+
24+
class DummyCfg:
25+
def __init__(self, url, graphspace, gs_supported, graph_name):
26+
self.url = url
27+
self.graphspace = graphspace
28+
self.gs_supported = gs_supported
29+
self.graph_name = graph_name
30+
31+
32+
class DummySession:
33+
"""Minimal session mimic implementing resolve and request used by router."""
34+
35+
def __init__(self, cfg: DummyCfg):
36+
self.cfg = cfg
37+
self.last = None
38+
39+
def resolve(self, path: str) -> str:
40+
base = f"{self.cfg.url.rstrip('/')}/"
41+
if self.cfg.gs_supported:
42+
base = urljoin(base, f"graphspaces/{self.cfg.graphspace}/graphs/{self.cfg.graph_name}/")
43+
else:
44+
base = urljoin(base, f"graphs/{self.cfg.graph_name}/")
45+
return urljoin(base, path).strip("/")
46+
47+
def request(self, path: str, method: str = "GET", validator=None, **kwargs):
48+
# mirror behavior of real session.request used by router: resolve path
49+
self.last = self.resolve(path)
50+
return {"url": self.last, "method": method}
51+
52+
53+
@pytest.mark.parametrize(
54+
"endpoint, method_call, args, expected_subpath",
55+
[
56+
("users", "list_users", (), "graphspaces/GS/auth/users"),
57+
("users", "get_user", ("u1",), "graphspaces/GS/auth/users/u1"),
58+
("accesses", "list_accesses", (), "graphspaces/GS/auth/accesses"),
59+
(
60+
"accesses",
61+
"get_accesses",
62+
("a1",),
63+
"graphspaces/GS/auth/accesses/a1",
64+
),
65+
("targets", "list_targets", (), "graphspaces/GS/auth/targets"),
66+
("belongs", "list_belongs", (), "graphspaces/GS/auth/belongs"),
67+
],
68+
)
69+
def test_graphspace_scoped_endpoints_use_graphspace(endpoint, method_call, args, expected_subpath):
70+
cfg = DummyCfg(url="http://127.0.0.1:8080", graphspace="GS", gs_supported=True, graph_name="g")
71+
sess = DummySession(cfg)
72+
auth = AuthManager(sess)
73+
74+
getattr(auth, method_call)(*args)
75+
assert expected_subpath in sess.last
76+
77+
78+
@pytest.mark.parametrize(
79+
"endpoint, method_call, args",
80+
[
81+
("users", "list_users", ()),
82+
("users", "get_user", ("u1",)),
83+
("accesses", "list_accesses", ()),
84+
("accesses", "get_accesses", ("a1",)),
85+
("targets", "list_targets", ()),
86+
("belongs", "list_belongs", ()),
87+
],
88+
)
89+
def test_graphspace_scoped_endpoints_require_graphspace(endpoint, method_call, args):
90+
# HugeGraph 1.7.0+ requires graphspace for these auth endpoints.
91+
cfg = DummyCfg(url="http://127.0.0.1:8080", graphspace=None, gs_supported=False, graph_name="g")
92+
sess = DummySession(cfg)
93+
auth = AuthManager(sess)
94+
95+
with pytest.raises(ValueError, match="graphspace is required for auth endpoints"):
96+
getattr(auth, method_call)(*args)
97+
98+
99+
def test_groups_are_server_level():
100+
# With graphspace support
101+
cfg = DummyCfg(url="http://127.0.0.1:8080", graphspace="GS", gs_supported=True, graph_name="g")
102+
sess = DummySession(cfg)
103+
auth = AuthManager(sess)
104+
auth.list_groups()
105+
assert "auth/groups" in sess.last
106+
107+
# Without graphspace support
108+
cfg2 = DummyCfg(url="http://127.0.0.1:8080", graphspace=None, gs_supported=False, graph_name="g")
109+
sess2 = DummySession(cfg2)
110+
auth2 = AuthManager(sess2)
111+
auth2.list_groups()
112+
assert "auth/groups" in sess2.last

0 commit comments

Comments
 (0)