Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions .github/workflows/ruff.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
imbajin marked this conversation as resolved.

- name: Check code formatting with Ruff
run: |
Expand Down
61 changes: 35 additions & 26 deletions hugegraph-python-client/src/pyhugegraph/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -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 {}
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -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()
25 changes: 24 additions & 1 deletion hugegraph-python-client/src/pyhugegraph/utils/huge_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 5 additions & 14 deletions hugegraph-python-client/src/tests/api/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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"])
Expand Down
112 changes: 112 additions & 0 deletions hugegraph-python-client/src/tests/api/test_auth_routing.py
Original file line number Diff line number Diff line change
@@ -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"),
Comment thread
Muawiya-contact marked this conversation as resolved.
("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
Loading