From 6f02d18db82ccc2ec9b2881de27676b7f198b8ba Mon Sep 17 00:00:00 2001 From: tada5hi Date: Wed, 29 Jul 2026 14:40:44 +0200 Subject: [PATCH] feat!: unwrap authup single-resource envelope authup wraps every single-record entity response in a data/meta envelope since 1.0.0-beta.57 (authup/authup@00f2f4c3a, "feat: query-capability discovery via meta.schema + entity record response envelope", #3332). getOne, create, update and delete of all entity controllers now respond with {"data": ..., "meta": ...} instead of the entity itself, mirroring the envelope that collection responses have always used. meta carries response-scoped extras such as the queryable schema of the endpoint. The OAuth2 protocol surface (token, introspect, revoke, ...) stays flat, so _auth_flows is unaffected. Add the _unwrap_single_resource hook to BaseClient, which returns the response body unchanged, and call it in _create_resource, _get_single_resource and _update_resource. AuthClient overrides the hook to return the data property and raises a ValueError naming the required authup version if the property is absent. The FLAME Hub core and storage services keep responding with the bare entity, so CoreClient and StorageClient are untouched. Collection responses were already validated as an envelope by ResourceList. BREAKING CHANGE: AuthClient requires authup 1.0.0-beta.57 or newer. --- docs/clients_api.rst | 3 +- flame_hub/_auth_client.py | 22 ++++++++++++ flame_hub/_base_client.py | 30 ++++++++++++++-- tests/test_base_client.py | 75 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 125 insertions(+), 5 deletions(-) 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 063750d..d6e1a15 100644 --- a/flame_hub/_auth_client.py +++ b/flame_hub/_auth_client.py @@ -298,6 +298,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]) -> list[Realm]: return self._get_all_resources(Realm, "realms", **params) diff --git a/flame_hub/_base_client.py b/flame_hub/_base_client.py index f009148..7e02b5d 100644 --- a/flame_hub/_base_client.py +++ b/flame_hub/_base_client.py @@ -410,6 +410,30 @@ def __init__(self, base_url: str, auth: PasswordAuth | ClientAuth | None = None, client = kwargs.get("client", None) self._client = client or httpx.Client(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 _get_all_resources( self, resource_type: type[ResourceT], @@ -561,7 +585,7 @@ def _create_resource( if r.status_code != expected_code: raise new_hub_api_error_from_response(r) - return resource_type(**r.json()) + return resource_type(**self._unwrap_single_resource(r.json())) def _get_single_resource( self, @@ -629,7 +653,7 @@ def _get_single_resource( if r.status_code != expected_code: raise new_hub_api_error_from_response(r) - return resource_type(**r.json()) + return resource_type(**self._unwrap_single_resource(r.json())) def _update_resource( self, @@ -681,7 +705,7 @@ def _update_resource( if r.status_code != expected_code: raise new_hub_api_error_from_response(r) - return resource_type(**r.json()) + return resource_type(**self._unwrap_single_resource(r.json())) def _delete_resource(self, *path: str | UuidIdentifiable, expected_code: int = httpx.codes.ACCEPTED.value) -> None: """Delete a resource of a certain type at the specified path. diff --git a/tests/test_base_client.py b/tests/test_base_client.py index ff5bd03..ca28e60 100644 --- a/tests/test_base_client.py +++ b/tests/test_base_client.py @@ -1,9 +1,11 @@ import typing as t import uuid +import httpx from pydantic import BaseModel, WrapValidator, Field, ValidationError import pytest +from flame_hub import AuthClient from flame_hub._base_client import ( build_page_params, build_filter_params, @@ -21,7 +23,7 @@ UNSET_T, ) from flame_hub.types import FilterOperator -from flame_hub.models import Node, User, Bucket +from flame_hub.models import Node, Realm, User, Bucket @pytest.mark.parametrize( @@ -202,6 +204,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 = 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 = 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 = 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",