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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ All notable changes to this project are documented here. The format follows
count rule.
- Updated examples to prefer explicit nested class selectors and documented
application-lifetime client reuse, HTTP connection pooling, and shutdown.
- Returned group memberships as contract-validated `PrincipalMember` values
and added cursor-aware membership page and collection helpers.

### Security

Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,13 @@ and item limits. The fluent `data()` selector supports nested paths, typed
comparisons, arrays, nulls, object keys, and IP/network operators; see
[Queries and pagination](docs/querying.md).

Group membership uses the same typed, cursor-aware interface:

```python
member_page = client.groups.members_page(group_id, Query().limit(25).include_total())
all_members = client.groups.all_members(group_id, max_items=5_000)
```

## Documentation

- [Client setup](docs/client.md)
Expand Down
9 changes: 9 additions & 0 deletions docs/querying.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,15 @@ The default bounds are intentionally conservative. Increase them explicitly for
a known large result set, or consume pages individually when streaming work is
more appropriate.

Group members are returned as typed `PrincipalMember` values. Membership
pagination follows the same bounds and cursor rules through `members_page()`,
`member_pages()`, and `all_members()`:

```python
page = client.groups.members_page(group_id, Query().limit(50).include_total())
members = client.groups.all_members(group_id, max_pages=20, max_items=5_000)
```

## Exact-name routes

Hubuum v0.0.3 supports explicit natural-key aliases for classes and objects.
Expand Down
2 changes: 2 additions & 0 deletions src/hubuum_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
ObjectRelation,
ObjectRelationCreate,
ObjectUpdate,
PrincipalMember,
Task,
TaskStatus,
User,
Expand Down Expand Up @@ -110,6 +111,7 @@
"Page",
"PermissionDeniedError",
"PrincipalId",
"PrincipalMember",
"Query",
"QueryFilter",
"RateLimitError",
Expand Down
48 changes: 44 additions & 4 deletions src/hubuum_client/async_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
ObjectRelation,
ObjectRelationCreate,
ObjectUpdate,
PrincipalMember,
Task,
User,
UserCreate,
Expand Down Expand Up @@ -479,11 +480,50 @@ def __init__(self, client: AsyncClient) -> None:
async def get_by_name(self, name: str) -> Group:
return await self.one(Query().where("groupname", name))

async def members(self, group_id: GroupId | int) -> list[dict[str, object]]:
value = await self._client.request(
"GET", f"/api/v1/iam/groups/{_segment(group_id)}/members"
def _members_service(
self, group_id: GroupId | int
) -> AsyncResourceService[PrincipalMember, BaseModel, BaseModel]:
path = f"/api/v1/iam/groups/{_segment(group_id)}/members"
return AsyncResourceService(
self._client,
collection_path=path,
item_path=f"{path}/{{id}}",
model=PrincipalMember,
)

async def members_page(
self, group_id: GroupId | int, query: Query | None = None
) -> Page[PrincipalMember]:
return await self._members_service(group_id).page(query)

async def members(
self, group_id: GroupId | int, query: Query | None = None
) -> builtins.list[PrincipalMember]:
return await self._members_service(group_id).list(query)

async def member_pages(
self,
group_id: GroupId | int,
query: Query | None = None,
*,
max_pages: int = 100,
) -> AsyncIterator[Page[PrincipalMember]]:
async for page in self._members_service(group_id).pages(query, max_pages=max_pages):
yield page

async def all_members(
self,
group_id: GroupId | int,
query: Query | None = None,
*,
max_pages: int = 100,
max_items: int = 10_000,
) -> builtins.list[PrincipalMember]:
return await self._members_service(group_id).all(
query,
max_pages=max_pages,
max_items=max_items,
)
return value if isinstance(value, list) else []

async def add_member(self, group_id: GroupId | int, principal_id: PrincipalId | int) -> None:
await self._client.request(
Expand Down
7 changes: 5 additions & 2 deletions src/hubuum_client/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,10 +274,13 @@ class ObjectRelationCreate(RequestModel):
class_relation_id: ClassRelationId


class Principal(HubuumModel):
class PrincipalMember(HubuumModel):
principal_id: PrincipalId
identity_scope: str
kind: str
name: str
kind: str | None = None
created_at: datetime
updated_at: datetime


class TaskStatus(StrEnum):
Expand Down
47 changes: 44 additions & 3 deletions src/hubuum_client/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
ObjectRelation,
ObjectRelationCreate,
ObjectUpdate,
PrincipalMember,
Task,
User,
UserCreate,
Expand Down Expand Up @@ -457,9 +458,49 @@ def __init__(self, client: Client) -> None:
def get_by_name(self, name: str) -> Group:
return self.one(Query().where("groupname", name))

def members(self, group_id: GroupId | int) -> list[dict[str, object]]:
value = self._client.request("GET", f"/api/v1/iam/groups/{_segment(group_id)}/members")
return value if isinstance(value, list) else []
def _members_service(
self, group_id: GroupId | int
) -> ResourceService[PrincipalMember, BaseModel, BaseModel]:
path = f"/api/v1/iam/groups/{_segment(group_id)}/members"
return ResourceService(
self._client,
collection_path=path,
item_path=f"{path}/{{id}}",
model=PrincipalMember,
)

def members_page(
self, group_id: GroupId | int, query: Query | None = None
) -> Page[PrincipalMember]:
return self._members_service(group_id).page(query)

def members(
self, group_id: GroupId | int, query: Query | None = None
) -> builtins.list[PrincipalMember]:
return self._members_service(group_id).list(query)

def member_pages(
self,
group_id: GroupId | int,
query: Query | None = None,
*,
max_pages: int = 100,
) -> Iterator[Page[PrincipalMember]]:
return self._members_service(group_id).pages(query, max_pages=max_pages)

def all_members(
self,
group_id: GroupId | int,
query: Query | None = None,
*,
max_pages: int = 100,
max_items: int = 10_000,
) -> builtins.list[PrincipalMember]:
return self._members_service(group_id).all(
query,
max_pages=max_pages,
max_items=max_items,
)

def add_member(self, group_id: GroupId | int, principal_id: PrincipalId | int) -> None:
self._client.request(
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ def test_iam_and_relations(client: Client, admin_group_id: GroupId, unique_name:
object_relation = None
try:
client.groups.add_member(group.id, PrincipalId(user.id))
assert any(member["principal_id"] == user.id for member in client.groups.members(group.id))
assert any(member.principal_id == user.id for member in client.groups.members(group.id))
client.groups.remove_member(group.id, PrincipalId(user.id))

for suffix in ("from", "to"):
Expand Down
59 changes: 55 additions & 4 deletions tests/unit/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
ObjectRelationId,
ObjectUpdate,
PrincipalId,
PrincipalMember,
Query,
RequestOptions,
ResultCardinalityError,
Expand Down Expand Up @@ -88,6 +89,17 @@ def _user_json() -> dict[str, Any]:
}


def _principal_member_json() -> dict[str, Any]:
return {
"principal_id": 21,
"identity_scope": "local",
"kind": "user",
"name": "alice",
"created_at": "2026-07-21T10:00:00Z",
"updated_at": "2026-07-21T10:00:00Z",
}


def _class_relation_json() -> dict[str, Any]:
return {
"id": 30,
Expand Down Expand Up @@ -208,7 +220,11 @@ def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=[_user_json()])
return httpx.Response(200, json=_user_json())
if path.endswith("/members"):
return httpx.Response(200, json=[{"principal_id": 21, "name": "alice"}])
return httpx.Response(
200,
headers={"x-total-count": "1", "x-page-limit": "25"},
json=[_principal_member_json()],
)
if "/members/" in path:
return httpx.Response(204)
if request.method == "GET" and path == "/api/v1/iam/groups":
Expand Down Expand Up @@ -245,7 +261,14 @@ def handler(request: httpx.Request) -> httpx.Response:
assert client.groups.get(20).groupname == "ops"
assert client.groups.get_by_name("ops").id == GroupId(20)
assert client.groups.update(20, GroupUpdate(groupname="platform")).id == GroupId(20)
assert client.groups.members(20)[0]["principal_id"] == 21
member: PrincipalMember = client.groups.members(20)[0]
assert member.principal_id == PrincipalId(21)
page = client.groups.members_page(20, Query().limit(25).include_total())
assert page.items == (member,)
assert page.total_count == 1
assert page.page_limit == 25
assert [item for page in client.groups.member_pages(20) for item in page] == [member]
assert client.groups.all_members(20) == [member]
client.groups.add_member(20, PrincipalId(21))
client.groups.remove_member(20, PrincipalId(21))
client.groups.delete(20)
Expand All @@ -254,6 +277,15 @@ def handler(request: httpx.Request) -> httpx.Response:
assert ("POST", "/api/v1/iam/groups/20/members/21") in seen


def test_sync_group_members_reject_non_array_response() -> None:
transport = httpx.MockTransport(lambda request: httpx.Response(200, json={}))
with (
Client("https://hubuum.test", token="token", transport=transport) as client,
pytest.raises(DecodeError, match="expected a JSON array"),
):
client.groups.members(20)


def test_sync_relations_tasks_probes_and_service_properties() -> None:
def handler(request: httpx.Request) -> httpx.Response:
path = request.url.path
Expand Down Expand Up @@ -477,7 +509,11 @@ def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=[_user_json()])
return httpx.Response(200, json=_user_json())
if path.endswith("/members"):
return httpx.Response(200, json=[])
return httpx.Response(
200,
headers={"x-total-count": "1", "x-page-limit": "25"},
json=[_principal_member_json()],
)
if "/members/" in path:
return httpx.Response(204)
if request.method == "GET" and path == "/api/v1/iam/groups":
Expand All @@ -502,12 +538,27 @@ def handler(request: httpx.Request) -> httpx.Response:
assert (await client.groups.get(20)).id == 20
assert (await client.groups.get_by_name("ops")).id == 20
assert (await client.groups.update(20, GroupUpdate(groupname="platform"))).id == 20
assert await client.groups.members(20) == []
member: PrincipalMember = (await client.groups.members(20))[0]
assert member.principal_id == PrincipalId(21)
page = await client.groups.members_page(20, Query().limit(25).include_total())
assert page.items == (member,)
assert page.total_count == 1
assert page.page_limit == 25
pages = [page async for page in client.groups.member_pages(20)]
assert [item for page in pages for item in page] == [member]
assert await client.groups.all_members(20) == [member]
await client.groups.add_member(20, PrincipalId(21))
await client.groups.remove_member(20, PrincipalId(21))
await client.groups.delete(20)


async def test_async_group_members_reject_non_array_response() -> None:
transport = httpx.MockTransport(lambda request: httpx.Response(200, json={}))
async with AsyncClient("https://hubuum.test", token="token", transport=transport) as client:
with pytest.raises(DecodeError, match="expected a JSON array"):
await client.groups.members(20)


async def test_async_timeout_and_transport_error() -> None:
def queued(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=_task_json("queued"))
Expand Down