This is an automated email from the ASF dual-hosted git repository.
imbajin pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hugegraph-ai.git
The following commit(s) were added to refs/heads/main by this push:
new 168adf9f fix(client): implement graphspace auth routing (#325)
168adf9f is described below
commit 168adf9fa04bbac54af43daa24ceae2fd96245b1
Author: Moavia Amir <[email protected]>
AuthorDate: Fri May 8 18:28:32 2026 +0500
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 <[email protected]>
Co-authored-by: imbajin <[email protected]>
---
.github/workflows/ruff.yml | 8 +-
.../src/pyhugegraph/api/auth.py | 61 ++++++-----
.../src/pyhugegraph/utils/huge_router.py | 25 ++++-
hugegraph-python-client/src/tests/api/test_auth.py | 19 +---
.../src/tests/api/test_auth_routing.py | 112 +++++++++++++++++++++
5 files changed, 178 insertions(+), 47 deletions(-)
diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml
index f158a62e..da3c3172 100644
--- a/.github/workflows/ruff.yml
+++ b/.github/workflows/ruff.yml
@@ -38,13 +38,9 @@ jobs:
restore-keys: |
${{ runner.os }}-uv-${{ matrix.python-version }}-
- - name: Install dependencies
+ - name: Install dev dependencies
run: |
- uv sync --extra all --extra dev
-
- - name: Check DGL version
- run: |
- uv run python -c "import dgl; print(dgl.__version__)"
+ uv sync --extra dev
- name: Check code formatting with Ruff
run: |
diff --git a/hugegraph-python-client/src/pyhugegraph/api/auth.py
b/hugegraph-python-client/src/pyhugegraph/api/auth.py
index 16121474..f10c4c11 100644
--- a/hugegraph-python-client/src/pyhugegraph/api/auth.py
+++ b/hugegraph-python-client/src/pyhugegraph/api/auth.py
@@ -22,19 +22,24 @@ from pyhugegraph.api.common import HugeParamsBase
from pyhugegraph.utils import huge_router as router
-# NOTE: Auth endpoints currently use absolute paths (/auth/...) which rely on a
-# temporary PathFilter compatibility layer in HugeGraph 1.7.0. This layer will
be
-# removed in future versions. When it is removed, these paths should be
converted
-# to relative paths (auth/...) with proper graphspace-scoped routing for
non-group
-# endpoints, similar to the Java Client's dual-path strategy.
-# See: apache/hugegraph-ai#322 (HugeGraph 1.7.0 auth API migration)
class AuthManager(HugeParamsBase):
- @router.http("GET", "/auth/users")
+ """Manage HugeGraph authentication and authorization.
+
+ The previous absolute /auth/... paths return 404 on HugeGraph 1.7.0+
+ because the server's JAX-RS @Path annotations only mount these endpoints
+ under /graphspaces/{graphspace}/auth/.... This change aligns the client
+ with the server's actual @Path annotations:
+ - users, accesses, belongs, targets -> graphspace-scoped
+ - groups -> server-level /auth/groups (matches GroupAPI @Path)
+ """
+
+ # User endpoints - graphspace-scoped
+ @router.http("GET", "/graphspaces/{graphspace}/auth/users")
def list_users(self, limit=None):
params = {"limit": limit} if limit is not None else {}
return self._invoke_request(params=params)
- @router.http("POST", "/auth/users")
+ @router.http("POST", "/graphspaces/{graphspace}/auth/users")
def create_user(self, user_name, user_password, user_phone=None,
user_email=None) -> dict | None:
return self._invoke_request(
data=json.dumps(
@@ -47,11 +52,11 @@ class AuthManager(HugeParamsBase):
)
)
- @router.http("DELETE", "/auth/users/{user_id}")
+ @router.http("DELETE", "/graphspaces/{graphspace}/auth/users/{user_id}")
def delete_user(self, user_id) -> dict | None:
return self._invoke_request()
- @router.http("PUT", "/auth/users/{user_id}")
+ @router.http("PUT", "/graphspaces/{graphspace}/auth/users/{user_id}")
def modify_user(
self,
user_id,
@@ -71,10 +76,11 @@ class AuthManager(HugeParamsBase):
)
)
- @router.http("GET", "/auth/users/{user_id}")
+ @router.http("GET", "/graphspaces/{graphspace}/auth/users/{user_id}")
def get_user(self, user_id) -> dict | None:
return self._invoke_request()
+ # Group endpoints - server-level (not graphspace-scoped per Java client
pattern)
@router.http("GET", "/auth/groups")
def list_groups(self, limit=None) -> dict | None:
params = {"limit": limit} if limit is not None else {}
@@ -103,7 +109,8 @@ class AuthManager(HugeParamsBase):
def get_group(self, group_id) -> dict | None:
return self._invoke_request()
- @router.http("POST", "/auth/accesses")
+ # Access endpoints - graphspace-scoped
+ @router.http("POST", "/graphspaces/{graphspace}/auth/accesses")
def grant_accesses(self, group_id, target_id, access_permission) -> dict |
None:
return self._invoke_request(
data=json.dumps(
@@ -115,24 +122,25 @@ class AuthManager(HugeParamsBase):
)
)
- @router.http("DELETE", "/auth/accesses/{access_id}")
+ @router.http("DELETE",
"/graphspaces/{graphspace}/auth/accesses/{access_id}")
def revoke_accesses(self, access_id) -> dict | None:
return self._invoke_request()
- @router.http("PUT", "/auth/accesses/{access_id}")
+ @router.http("PUT", "/graphspaces/{graphspace}/auth/accesses/{access_id}")
def modify_accesses(self, access_id, access_description) -> dict | None:
data = {"access_description": access_description}
return self._invoke_request(data=json.dumps(data))
- @router.http("GET", "/auth/accesses/{access_id}")
+ @router.http("GET", "/graphspaces/{graphspace}/auth/accesses/{access_id}")
def get_accesses(self, access_id) -> dict | None:
return self._invoke_request()
- @router.http("GET", "/auth/accesses")
+ @router.http("GET", "/graphspaces/{graphspace}/auth/accesses")
def list_accesses(self) -> dict | None:
return self._invoke_request()
- @router.http("POST", "/auth/targets")
+ # Target endpoints - graphspace-scoped
+ @router.http("POST", "/graphspaces/{graphspace}/auth/targets")
def create_target(self, target_name, target_graph, target_url,
target_resources) -> dict | None:
return self._invoke_request(
data=json.dumps(
@@ -145,11 +153,11 @@ class AuthManager(HugeParamsBase):
)
)
- @router.http("DELETE", "/auth/targets/{target_id}")
+ @router.http("DELETE",
"/graphspaces/{graphspace}/auth/targets/{target_id}")
def delete_target(self, target_id) -> None:
return self._invoke_request()
- @router.http("PUT", "/auth/targets/{target_id}")
+ @router.http("PUT", "/graphspaces/{graphspace}/auth/targets/{target_id}")
def update_target(
self,
target_id,
@@ -169,32 +177,33 @@ class AuthManager(HugeParamsBase):
)
)
- @router.http("GET", "/auth/targets/{target_id}")
+ @router.http("GET", "/graphspaces/{graphspace}/auth/targets/{target_id}")
def get_target(self, target_id, response=None) -> dict | None:
return self._invoke_request()
- @router.http("GET", "/auth/targets")
+ @router.http("GET", "/graphspaces/{graphspace}/auth/targets")
def list_targets(self) -> dict | None:
return self._invoke_request()
- @router.http("POST", "/auth/belongs")
+ # Belong endpoints - graphspace-scoped
+ @router.http("POST", "/graphspaces/{graphspace}/auth/belongs")
def create_belong(self, user_id, group_id) -> dict | None:
data = {"user": user_id, "group": group_id}
return self._invoke_request(data=json.dumps(data))
- @router.http("DELETE", "/auth/belongs/{belong_id}")
+ @router.http("DELETE",
"/graphspaces/{graphspace}/auth/belongs/{belong_id}")
def delete_belong(self, belong_id) -> None:
return self._invoke_request()
- @router.http("PUT", "/auth/belongs/{belong_id}")
+ @router.http("PUT", "/graphspaces/{graphspace}/auth/belongs/{belong_id}")
def update_belong(self, belong_id, description) -> dict | None:
data = {"belong_description": description}
return self._invoke_request(data=json.dumps(data))
- @router.http("GET", "/auth/belongs/{belong_id}")
+ @router.http("GET", "/graphspaces/{graphspace}/auth/belongs/{belong_id}")
def get_belong(self, belong_id) -> dict | None:
return self._invoke_request()
- @router.http("GET", "/auth/belongs")
+ @router.http("GET", "/graphspaces/{graphspace}/auth/belongs")
def list_belongs(self) -> dict | None:
return self._invoke_request()
diff --git a/hugegraph-python-client/src/pyhugegraph/utils/huge_router.py
b/hugegraph-python-client/src/pyhugegraph/utils/huge_router.py
index 48a9b381..580f78f0 100644
--- a/hugegraph-python-client/src/pyhugegraph/utils/huge_router.py
+++ b/hugegraph-python-client/src/pyhugegraph/utils/huge_router.py
@@ -126,7 +126,30 @@ def http(method: str, path: str) -> Callable:
all_kwargs = dict(bound_args.arguments)
# Remove 'self' from the arguments used to format the pathinfo
all_kwargs.pop("self")
- formatted_path = path.format(**all_kwargs)
+
+ # Graphspace-scoped auth paths require a graphspace: HugeGraph
1.7.0+
+ # only mounts UserAPI/AccessAPI/BelongAPI/TargetAPI under
+ # /graphspaces/{graphspace}/auth/..., so we fail fast when the
+ # session lacks one rather than producing an unreachable URL.
+ if "{graphspace}" in path:
+ graphspace_arg = all_kwargs.get("graphspace")
+ graphspace_cfg = getattr(self.session.cfg, "graphspace",
None)
+ gs_supported = getattr(self.session.cfg, "gs_supported",
False)
+
+ if not (graphspace_arg or (graphspace_cfg and
gs_supported)):
+ raise ValueError(
+ "graphspace is required for auth endpoints on
HugeGraph 1.7.0+. "
+ "Ensure gs_supported is True and graphspace is
configured."
+ )
+
+ prefix = "/graphspaces/{graphspace}"
+ if not path.startswith(prefix + "/"):
+ raise ValueError(f"Expected graphspace-prefixed path,
got: {path}")
+
+ all_kwargs["graphspace"] = graphspace_arg or graphspace_cfg
+ formatted_path = path.format(**all_kwargs)
+ else:
+ formatted_path = path.format(**all_kwargs)
else:
formatted_path = path
diff --git a/hugegraph-python-client/src/tests/api/test_auth.py
b/hugegraph-python-client/src/tests/api/test_auth.py
index 943650ea..1f6dec95 100644
--- a/hugegraph-python-client/src/tests/api/test_auth.py
+++ b/hugegraph-python-client/src/tests/api/test_auth.py
@@ -26,29 +26,17 @@ from ..client_utils import ClientUtils
class TestAuthManager(unittest.TestCase):
client = None
auth = None
- skip_auth_tests = False
@classmethod
def setUpClass(cls):
cls.client = ClientUtils()
cls.auth = cls.client.auth
- # Check if auth endpoints are available
- try:
- cls.auth.list_users()
- except NotFoundError as e:
- if "404" in str(e) or "Not Found" in str(e):
- cls.skip_auth_tests = True
- else:
- raise
@classmethod
def tearDownClass(cls):
- if not cls.skip_auth_tests:
- cls.client.clear_graph_all_data()
+ cls.client.clear_graph_all_data()
def setUp(self):
- if self.skip_auth_tests:
- self.skipTest("Auth endpoints not available in this server")
users = self.auth.list_users()
for user in users["users"]:
if user["user_creator"] != "system":
@@ -146,7 +134,10 @@ class TestAuthManager(unittest.TestCase):
[{"type": "VERTEX", "label": "person", "properties": {"city":
"Shanghai"}}],
)
# Verify the target was modified
- self.assertEqual(target["target_resources"][0]["properties"]["city"],
"Shanghai")
+ # HugeGraph 1.7.0+ returns target_resources as a keyed map such as
+ # {"VERTEX#person": [{...}]}; older payloads used a list shape.
+ target_resources = target["target_resources"]
+
self.assertEqual(target_resources["VERTEX#person"][0]["properties"]["city"],
"Shanghai")
# Delete the target
self.auth.delete_target(target["id"])
diff --git a/hugegraph-python-client/src/tests/api/test_auth_routing.py
b/hugegraph-python-client/src/tests/api/test_auth_routing.py
new file mode 100644
index 00000000..e7a6e105
--- /dev/null
+++ b/hugegraph-python-client/src/tests/api/test_auth_routing.py
@@ -0,0 +1,112 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from urllib.parse import urljoin
+
+import pytest
+from pyhugegraph.api.auth import AuthManager
+
+
+class DummyCfg:
+ def __init__(self, url, graphspace, gs_supported, graph_name):
+ self.url = url
+ self.graphspace = graphspace
+ self.gs_supported = gs_supported
+ self.graph_name = graph_name
+
+
+class DummySession:
+ """Minimal session mimic implementing resolve and request used by
router."""
+
+ def __init__(self, cfg: DummyCfg):
+ self.cfg = cfg
+ self.last = None
+
+ def resolve(self, path: str) -> str:
+ base = f"{self.cfg.url.rstrip('/')}/"
+ if self.cfg.gs_supported:
+ base = urljoin(base,
f"graphspaces/{self.cfg.graphspace}/graphs/{self.cfg.graph_name}/")
+ else:
+ base = urljoin(base, f"graphs/{self.cfg.graph_name}/")
+ return urljoin(base, path).strip("/")
+
+ def request(self, path: str, method: str = "GET", validator=None,
**kwargs):
+ # mirror behavior of real session.request used by router: resolve path
+ self.last = self.resolve(path)
+ return {"url": self.last, "method": method}
+
+
[email protected](
+ "endpoint, method_call, args, expected_subpath",
+ [
+ ("users", "list_users", (), "graphspaces/GS/auth/users"),
+ ("users", "get_user", ("u1",), "graphspaces/GS/auth/users/u1"),
+ ("accesses", "list_accesses", (), "graphspaces/GS/auth/accesses"),
+ (
+ "accesses",
+ "get_accesses",
+ ("a1",),
+ "graphspaces/GS/auth/accesses/a1",
+ ),
+ ("targets", "list_targets", (), "graphspaces/GS/auth/targets"),
+ ("belongs", "list_belongs", (), "graphspaces/GS/auth/belongs"),
+ ],
+)
+def test_graphspace_scoped_endpoints_use_graphspace(endpoint, method_call,
args, expected_subpath):
+ cfg = DummyCfg(url="http://127.0.0.1:8080", graphspace="GS",
gs_supported=True, graph_name="g")
+ sess = DummySession(cfg)
+ auth = AuthManager(sess)
+
+ getattr(auth, method_call)(*args)
+ assert expected_subpath in sess.last
+
+
[email protected](
+ "endpoint, method_call, args",
+ [
+ ("users", "list_users", ()),
+ ("users", "get_user", ("u1",)),
+ ("accesses", "list_accesses", ()),
+ ("accesses", "get_accesses", ("a1",)),
+ ("targets", "list_targets", ()),
+ ("belongs", "list_belongs", ()),
+ ],
+)
+def test_graphspace_scoped_endpoints_require_graphspace(endpoint, method_call,
args):
+ # HugeGraph 1.7.0+ requires graphspace for these auth endpoints.
+ cfg = DummyCfg(url="http://127.0.0.1:8080", graphspace=None,
gs_supported=False, graph_name="g")
+ sess = DummySession(cfg)
+ auth = AuthManager(sess)
+
+ with pytest.raises(ValueError, match="graphspace is required for auth
endpoints"):
+ getattr(auth, method_call)(*args)
+
+
+def test_groups_are_server_level():
+ # With graphspace support
+ cfg = DummyCfg(url="http://127.0.0.1:8080", graphspace="GS",
gs_supported=True, graph_name="g")
+ sess = DummySession(cfg)
+ auth = AuthManager(sess)
+ auth.list_groups()
+ assert "auth/groups" in sess.last
+
+ # Without graphspace support
+ cfg2 = DummyCfg(url="http://127.0.0.1:8080", graphspace=None,
gs_supported=False, graph_name="g")
+ sess2 = DummySession(cfg2)
+ auth2 = AuthManager(sess2)
+ auth2.list_groups()
+ assert "auth/groups" in sess2.last