Skip to content
Open
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
3 changes: 2 additions & 1 deletion docs/clients_api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ Clients

.. autoclass:: flame_hub._base_client.BaseClient
:private-members: _get_all_resources, _find_all_resources, _create_resource, _get_single_resource, _update_resource,
_delete_resource
_delete_resource, _unwrap_single_resource

.. autoclass:: flame_hub.AuthClient
:members:
:undoc-members:
:private-members: _unwrap_single_resource

.. autoclass:: flame_hub.CoreClient
:members:
Expand Down
22 changes: 22 additions & 0 deletions flame_hub/_auth_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,28 @@ def __init__(
):
super().__init__(base_url, auth, **kwargs)

def _unwrap_single_resource(self, body: t.Any) -> t.Any:
"""Extract the resource object from authup's single-resource envelope.

Since ``1.0.0-beta.57`` authup responds to single-resource requests with :python:`{"data": ..., "meta": ...}`
instead of the resource object itself, mirroring the envelope that list responses have always used. ``meta``
holds response-scoped extras such as the queryable schema of the endpoint and is discarded.

Raises
------
:py:exc:`ValueError`
If ``body`` does not carry a ``data`` property, which is the case for authup versions before
``1.0.0-beta.57``.

See Also
--------
:py:meth:`.BaseClient._unwrap_single_resource`
"""
if not isinstance(body, dict) or "data" not in body:
raise ValueError("response body is not wrapped in a data property, authup 1.0.0-beta.57 or newer required")

return body["data"]

def get_realms(self, **params: te.Unpack[GetKwargs]) -> ResourceListResult[Realm]:
return self._get_all_resources(Realm, "realms", **params)

Expand Down
30 changes: 27 additions & 3 deletions flame_hub/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,30 @@ def __init__(
client = kwargs.get("client", None)
self._client = client or httpx.Client(auth=resolve_auth(auth), base_url=base_url)

def _unwrap_single_resource(self, body: t.Any) -> t.Any:
"""Extract the resource object from the body of a single-resource response.

The FLAME Hub core and storage services respond to single-resource requests with the resource object itself,
which is why this implementation returns ``body`` unchanged. Clients whose service wraps the resource in an
envelope override this method.

Parameters
----------
body : :py:obj:`~typing.Any`
Deserialized response body of a request which targets a single resource.

Returns
-------
:py:obj:`~typing.Any`
The object which is validated with the resource model.

See Also
--------
:py:meth:`._get_single_resource`, :py:meth:`._create_resource`, :py:meth:`._update_resource`,\
:py:meth:`.AuthClient._unwrap_single_resource`
"""
return body

def _request(
self,
method: t.Literal["GET", "POST", "PUT", "DELETE"],
Expand Down Expand Up @@ -637,7 +661,7 @@ def _create_resource(

r = self._request("POST", *path, expected_code=expected_code, json=resource.model_dump(mode="json"), **params)

return resource_type(**r.json())
return resource_type(**self._unwrap_single_resource(r.json()))

def _get_single_resource(
self,
Expand Down Expand Up @@ -706,7 +730,7 @@ def _get_single_resource(
else:
raise

return resource_type(**r.json())
return resource_type(**self._unwrap_single_resource(r.json()))

def _update_resource(
self,
Expand Down Expand Up @@ -760,7 +784,7 @@ def _update_resource(
**params,
)

return resource_type(**r.json())
return resource_type(**self._unwrap_single_resource(r.json()))

def _delete_resource(
self,
Expand Down
73 changes: 72 additions & 1 deletion tests/test_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
)
from flame_hub.auth import ClientAuth, PasswordAuth, StaticAuth
from flame_hub.types import FilterOperator
from flame_hub.models import Node, User, Bucket, RefreshToken
from flame_hub.models import Node, Realm, User, Bucket, RefreshToken
from tests.helpers import next_random_string


Expand Down Expand Up @@ -207,6 +207,77 @@ def test_get_includable_names(model, includable_properties):
assert get_includable_names(model) == includable_properties


def new_mock_client(body: t.Any, status_code: int = httpx.codes.OK.value) -> httpx.Client:
"""Create an HTTP client which answers every request with the same status code and body."""
return httpx.Client(
base_url="http://localhost",
transport=httpx.MockTransport(lambda _: httpx.Response(status_code, json=body)),
)


REALM_JSON = {
"id": "36b6a1a4-2fdb-4ec3-a1a9-4b1f0aa0f9de",
"name": "master",
"displayName": "Master Realm",
"description": None,
"builtIn": True,
"createdAt": "2026-07-20T09:25:01.000Z",
"updatedAt": "2026-07-20T09:25:01.000Z",
}

NODE_JSON = {
"id": "5a0a3b0e-e4a0-4a41-9d7f-e2b9dcdcf9c1",
"name": "my-node",
"external_name": None,
"hidden": False,
"realm_id": "9d6b7a44-3f66-4f8b-9d2e-9dd0d2d7b2c1",
"registry_id": None,
"type": "default",
"public_key": None,
"online": False,
"registry_project_id": None,
"robot_id": None,
"client_id": None,
"created_at": "2026-07-20T09:25:01.000Z",
"updated_at": "2026-07-20T09:25:01.000Z",
}


@pytest.mark.parametrize(
"status_code,call_client",
[
(httpx.codes.OK.value, lambda c: c.get_realm(REALM_JSON["id"])),
(httpx.codes.CREATED.value, lambda c: c.create_realm(REALM_JSON["name"])),
(httpx.codes.ACCEPTED.value, lambda c: c.update_realm(REALM_JSON["id"], name=REALM_JSON["name"])),
],
)
def test_auth_client_unwraps_single_resource(status_code, call_client):
# authup wraps single resources as {"data": ..., "meta": ...} since 1.0.0-beta.57
auth_client = flame_hub.AuthClient(client=new_mock_client({"data": REALM_JSON, "meta": {}}, status_code))

assert call_client(auth_client) == Realm(**REALM_JSON)


def test_auth_client_rejects_unwrapped_single_resource():
auth_client = flame_hub.AuthClient(client=new_mock_client(REALM_JSON))

with pytest.raises(ValueError, match="data property"):
auth_client.get_realm(REALM_JSON["id"])


def test_auth_client_keeps_list_response_untouched():
auth_client = flame_hub.AuthClient(client=new_mock_client({"data": [REALM_JSON], "meta": {"total": 1}}))

assert auth_client.get_realms() == [Realm(**REALM_JSON)]


def test_base_client_keeps_single_resource_untouched():
# the FLAME Hub core and storage services respond with the resource itself
client = BaseClient(base_url="http://localhost", client=new_mock_client(NODE_JSON))

assert client._get_single_resource(Node, "nodes", NODE_JSON["id"]) == Node(**NODE_JSON)


@pytest.mark.integration
@pytest.mark.parametrize(
"resource_type,base_url_fixture_name,path",
Expand Down