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.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 @@ def create_user(self, user_name, user_password, user_phone=None, user_email=None ) ) - @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 @@ def modify_user( ) ) - @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 @@ def modify_group( 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 @@ def grant_accesses(self, group_id, target_id, access_permission) -> dict | None: ) ) - @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 @@ def create_target(self, target_name, target_graph, target_url, target_resources) ) ) - @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 @@ def update_target( ) ) - @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 wrapper(self: "HGraphContext", *args: Any, **kwargs: Any) -> Any: 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 @@ 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 @@ def test_target_operations(self): [{"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} + + +@pytest.mark.parametrize( + "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 + + +@pytest.mark.parametrize( + "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