diff --git a/docs/clients_api.rst b/docs/clients_api.rst index e7acfc9..323ca0e 100644 --- a/docs/clients_api.rst +++ b/docs/clients_api.rst @@ -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: diff --git a/flame_hub/_auth_client.py b/flame_hub/_auth_client.py index 3b9c89d..1102018 100644 --- a/flame_hub/_auth_client.py +++ b/flame_hub/_auth_client.py @@ -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) diff --git a/flame_hub/_base_client.py b/flame_hub/_base_client.py index 65748b8..401660f 100644 --- a/flame_hub/_base_client.py +++ b/flame_hub/_base_client.py @@ -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"], @@ -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, @@ -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, @@ -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, diff --git a/tests/test_base_client.py b/tests/test_base_client.py index eb7a2ff..3b82281 100644 --- a/tests/test_base_client.py +++ b/tests/test_base_client.py @@ -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 @@ -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",