Skip to content

Commit 31be192

Browse files
committed
Fix Flux role permission objects handling and response normalization
1 parent 3da8fed commit 31be192

5 files changed

Lines changed: 173 additions & 48 deletions

File tree

docs/management-client.md

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,8 @@ resource = client.upsert_resource(
246246

247247
Upsert many resources in parallel. The SDK fans out individual `upsert_resource()` calls using threads (sync client) or async tasks (async client), controlled by `max_concurrency`.
248248

249+
This is an SDK helper, not a separate Management API endpoint. Under the hood it executes concurrent `PUT /v1/:env/folders/:folder/resources/?external_id=<value>` calls.
250+
249251
```python
250252
from foxnose_sdk import BatchUpsertItem
251253

@@ -274,10 +276,10 @@ result = await client.batch_upsert_resources("folder-key", items, max_concurrenc
274276
- `fail_fast=True` — stop on the first error and raise it immediately.
275277

276278
```python
277-
# Raises FoxnoseAPIError on first failure
279+
# Raises on first failed upsert call
278280
try:
279281
result = client.batch_upsert_resources("folder-key", items, fail_fast=True)
280-
except FoxnoseAPIError as exc:
282+
except Exception as exc:
281283
print(f"Batch stopped: {exc}")
282284
```
283285

@@ -471,9 +473,23 @@ client.upsert_flux_role_permission(
471473
{
472474
"content_type": "flux-apis",
473475
"actions": ["read"],
474-
"all_objects": True,
476+
"all_objects": False,
477+
},
478+
)
479+
480+
# Scope access to specific Flux APIs
481+
client.add_flux_permission_object(
482+
"role-key",
483+
{
484+
"content_type": "flux-apis",
485+
"object_key": "api-key-1",
475486
},
476487
)
488+
489+
objects = client.list_flux_permission_objects(
490+
"role-key", content_type="flux-apis"
491+
)
492+
# objects is a list[RolePermissionObject]
477493
```
478494

479495
### API Keys

src/foxnose_sdk/flux/client.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -83,15 +83,17 @@ def search(
8383
path = self._build_path(folder_path, suffix="/_search")
8484
return self._transport.request("POST", path, json_body=body)
8585

86-
def get_router(self) -> Any:
86+
def get_router(self, *, params: Mapping[str, Any] | None = None) -> Any:
8787
"""Return available routes and contracts under the configured API prefix."""
8888
path = f"/{self.api_prefix}/_router"
89-
return self._transport.request("GET", path)
89+
return self._transport.request("GET", path, params=params)
9090

91-
def get_schema(self, folder_path: str) -> Any:
91+
def get_schema(
92+
self, folder_path: str, *, params: Mapping[str, Any] | None = None
93+
) -> Any:
9294
"""Return live JSON Schema and metadata for the given folder path."""
9395
path = self._build_path(folder_path, suffix="/_schema")
94-
return self._transport.request("GET", path)
96+
return self._transport.request("GET", path, params=params)
9597

9698
def close(self) -> None:
9799
self._transport.close()
@@ -163,15 +165,17 @@ async def search(
163165
path = self._build_path(folder_path, suffix="/_search")
164166
return await self._transport.arequest("POST", path, json_body=body)
165167

166-
async def get_router(self) -> Any:
168+
async def get_router(self, *, params: Mapping[str, Any] | None = None) -> Any:
167169
"""Return available routes and contracts under the configured API prefix."""
168170
path = f"/{self.api_prefix}/_router"
169-
return await self._transport.arequest("GET", path)
171+
return await self._transport.arequest("GET", path, params=params)
170172

171-
async def get_schema(self, folder_path: str) -> Any:
173+
async def get_schema(
174+
self, folder_path: str, *, params: Mapping[str, Any] | None = None
175+
) -> Any:
172176
"""Return live JSON Schema and metadata for the given folder path."""
173177
path = self._build_path(folder_path, suffix="/_schema")
174-
return await self._transport.arequest("GET", path)
178+
return await self._transport.arequest("GET", path, params=params)
175179

176180
async def aclose(self) -> None:
177181
await self._transport.aclose()

src/foxnose_sdk/management/client.py

Lines changed: 85 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,47 @@ def _resolve_key(value: str | BaseModel) -> str:
7070
return key
7171

7272

73+
def _coerce_list_payload(payload: Any) -> list[Any]:
74+
"""Normalize list-like API payloads.
75+
76+
Supports both legacy list responses and paginated envelopes that expose
77+
list items under ``results``.
78+
"""
79+
if payload is None:
80+
return []
81+
if isinstance(payload, list):
82+
return payload
83+
if isinstance(payload, dict):
84+
results = payload.get("results")
85+
if isinstance(results, list):
86+
return results
87+
return [payload]
88+
return [payload]
89+
90+
91+
def _coerce_permission_object_payload(
92+
payload: Any, request_payload: Mapping[str, Any]
93+
) -> dict[str, Any]:
94+
"""Normalize permission-object payloads for create operations.
95+
96+
Some API endpoints return `201 Created` with an empty response body.
97+
In that case we reconstruct the object from the original request payload.
98+
"""
99+
if isinstance(payload, Mapping):
100+
data = dict(payload)
101+
if "object_key" not in data and "object" in data:
102+
data["object_key"] = data.pop("object")
103+
return data
104+
105+
object_key = request_payload.get("object_key")
106+
if object_key is None:
107+
object_key = request_payload.get("object")
108+
return {
109+
"content_type": request_payload.get("content_type"),
110+
"object_key": object_key,
111+
}
112+
113+
73114
FolderRef = Union[str, FolderSummary]
74115
ResourceRef = Union[str, ResourceSummary]
75116
RevisionRef = Union[str, RevisionSummary]
@@ -719,8 +760,10 @@ def list_management_role_permissions(
719760
role_key: Unique identifier of the role.
720761
"""
721762
role_key = _resolve_key(role_key)
722-
payload = self.request("GET", f"{self._role_permissions_root(role_key)}/") or []
723-
return [RolePermission.model_validate(item) for item in payload]
763+
payload = self.request("GET", f"{self._role_permissions_root(role_key)}/")
764+
return [
765+
RolePermission.model_validate(item) for item in _coerce_list_payload(payload)
766+
]
724767

725768
def upsert_management_role_permission(
726769
self,
@@ -794,9 +837,11 @@ def list_management_permission_objects(
794837
self.request(
795838
"GET", f"{self._role_permission_objects_root(role_key)}/", params=params
796839
)
797-
or []
798840
)
799-
return [RolePermissionObject.model_validate(item) for item in payload]
841+
return [
842+
RolePermissionObject.model_validate(item)
843+
for item in _coerce_list_payload(payload)
844+
]
800845

801846
def add_management_permission_object(
802847
self,
@@ -815,7 +860,9 @@ def add_management_permission_object(
815860
f"{self._role_permission_objects_root(role_key)}/",
816861
json_body=payload,
817862
)
818-
return RolePermissionObject.model_validate(data)
863+
return RolePermissionObject.model_validate(
864+
_coerce_permission_object_payload(data, payload)
865+
)
819866

820867
def delete_management_permission_object(
821868
self,
@@ -897,10 +944,10 @@ def list_flux_role_permissions(self, role_key: FluxRoleRef) -> list[RolePermissi
897944
role_key: Unique identifier of the role.
898945
"""
899946
role_key = _resolve_key(role_key)
900-
payload = (
901-
self.request("GET", f"{self._flux_role_permissions_root(role_key)}/") or []
902-
)
903-
return [RolePermission.model_validate(item) for item in payload]
947+
payload = self.request("GET", f"{self._flux_role_permissions_root(role_key)}/")
948+
return [
949+
RolePermission.model_validate(item) for item in _coerce_list_payload(payload)
950+
]
904951

905952
def upsert_flux_role_permission(
906953
self, role_key: FluxRoleRef, payload: Mapping[str, Any]
@@ -972,9 +1019,11 @@ def list_flux_permission_objects(
9721019
f"{self._flux_role_permission_objects_root(role_key)}/",
9731020
params={"content_type": content_type},
9741021
)
975-
or []
9761022
)
977-
return [RolePermissionObject.model_validate(item) for item in payload]
1023+
return [
1024+
RolePermissionObject.model_validate(item)
1025+
for item in _coerce_list_payload(payload)
1026+
]
9781027

9791028
def add_flux_permission_object(
9801029
self, role_key: FluxRoleRef, payload: Mapping[str, Any]
@@ -991,7 +1040,9 @@ def add_flux_permission_object(
9911040
f"{self._flux_role_permission_objects_root(role_key)}/",
9921041
json_body=payload,
9931042
)
994-
return RolePermissionObject.model_validate(data)
1043+
return RolePermissionObject.model_validate(
1044+
_coerce_permission_object_payload(data, payload)
1045+
)
9951046

9961047
def delete_flux_permission_object(
9971048
self, role_key: FluxRoleRef, payload: Mapping[str, Any]
@@ -2664,10 +2715,10 @@ async def list_management_role_permissions(
26642715
self, role_key: ManagementRoleRef
26652716
) -> list[RolePermission]:
26662717
role_key = _resolve_key(role_key)
2667-
payload = (
2668-
await self.request("GET", f"{self._role_permissions_root(role_key)}/") or []
2669-
)
2670-
return [RolePermission.model_validate(item) for item in payload]
2718+
payload = await self.request("GET", f"{self._role_permissions_root(role_key)}/")
2719+
return [
2720+
RolePermission.model_validate(item) for item in _coerce_list_payload(payload)
2721+
]
26712722

26722723
async def upsert_management_role_permission(
26732724
self,
@@ -2716,8 +2767,10 @@ async def list_management_permission_objects(
27162767
f"{self._role_permission_objects_root(role_key)}/",
27172768
params={"content_type": content_type},
27182769
)
2719-
payload = payload or []
2720-
return [RolePermissionObject.model_validate(item) for item in payload]
2770+
return [
2771+
RolePermissionObject.model_validate(item)
2772+
for item in _coerce_list_payload(payload)
2773+
]
27212774

27222775
async def add_management_permission_object(
27232776
self,
@@ -2730,7 +2783,9 @@ async def add_management_permission_object(
27302783
f"{self._role_permission_objects_root(role_key)}/",
27312784
json_body=payload,
27322785
)
2733-
return RolePermissionObject.model_validate(data)
2786+
return RolePermissionObject.model_validate(
2787+
_coerce_permission_object_payload(data, payload)
2788+
)
27342789

27352790
async def delete_management_permission_object(
27362791
self,
@@ -2781,11 +2836,10 @@ async def list_flux_role_permissions(
27812836
self, role_key: FluxRoleRef
27822837
) -> list[RolePermission]:
27832838
role_key = _resolve_key(role_key)
2784-
payload = (
2785-
await self.request("GET", f"{self._flux_role_permissions_root(role_key)}/")
2786-
or []
2787-
)
2788-
return [RolePermission.model_validate(item) for item in payload]
2839+
payload = await self.request("GET", f"{self._flux_role_permissions_root(role_key)}/")
2840+
return [
2841+
RolePermission.model_validate(item) for item in _coerce_list_payload(payload)
2842+
]
27892843

27902844
async def upsert_flux_role_permission(
27912845
self, role_key: FluxRoleRef, payload: Mapping[str, Any]
@@ -2832,8 +2886,10 @@ async def list_flux_permission_objects(
28322886
f"{self._flux_role_permission_objects_root(role_key)}/",
28332887
params={"content_type": content_type},
28342888
)
2835-
payload = payload or []
2836-
return [RolePermissionObject.model_validate(item) for item in payload]
2889+
return [
2890+
RolePermissionObject.model_validate(item)
2891+
for item in _coerce_list_payload(payload)
2892+
]
28372893

28382894
async def add_flux_permission_object(
28392895
self,
@@ -2846,7 +2902,9 @@ async def add_flux_permission_object(
28462902
f"{self._flux_role_permission_objects_root(role_key)}/",
28472903
json_body=payload,
28482904
)
2849-
return RolePermissionObject.model_validate(data)
2905+
return RolePermissionObject.model_validate(
2906+
_coerce_permission_object_payload(data, payload)
2907+
)
28502908

28512909
async def delete_flux_permission_object(
28522910
self,

tests/test_async_clients.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -984,14 +984,22 @@ def handler(request: httpx.Request) -> httpx.Response:
984984
if request.method == "GET" and request.url.path.endswith(
985985
"/permissions/objects/"
986986
):
987-
return httpx.Response(200, json=[PERMISSION_OBJECT_JSON])
987+
return httpx.Response(
988+
200,
989+
json={
990+
"count": 1,
991+
"next": None,
992+
"previous": None,
993+
"results": [PERMISSION_OBJECT_JSON],
994+
},
995+
)
988996
if request.method == "GET" and request.url.path.endswith("/permissions/"):
989997
return httpx.Response(200, json=[ROLE_PERMISSION_JSON])
990998
if request.method == "POST" and request.url.path.endswith(
991999
"/permissions/objects/"
9921000
):
9931001
body = json.loads(request.content.decode())
994-
return httpx.Response(201, json=PERMISSION_OBJECT_JSON | body)
1002+
return httpx.Response(201)
9951003
if request.method == "POST" and request.url.path.endswith(
9961004
"/permissions/batch/"
9971005
):
@@ -1028,6 +1036,7 @@ def handler(request: httpx.Request) -> httpx.Response:
10281036
added = await client.add_management_permission_object(
10291037
"role-1", PERMISSION_OBJECT_JSON
10301038
)
1039+
assert added.content_type == "folder-items"
10311040
assert added.object_key == "folder-1"
10321041

10331042
await client.delete_management_permission_object("role-1", PERMISSION_OBJECT_JSON)
@@ -1036,21 +1045,29 @@ def handler(request: httpx.Request) -> httpx.Response:
10361045

10371046
@pytest.mark.asyncio
10381047
async def test_async_flux_role_permissions_workflow():
1039-
recorded: list[tuple[str, str]] = []
1048+
recorded: list[tuple[str, str, str]] = []
10401049

10411050
def handler(request: httpx.Request) -> httpx.Response:
1042-
recorded.append((request.method, request.url.path))
1051+
recorded.append((request.method, request.url.path, str(request.url)))
10431052
if request.method == "GET" and request.url.path.endswith(
10441053
"/permissions/objects/"
10451054
):
1046-
return httpx.Response(200, json=[FLUX_PERMISSION_OBJECT_JSON])
1055+
return httpx.Response(
1056+
200,
1057+
json={
1058+
"count": 1,
1059+
"next": None,
1060+
"previous": None,
1061+
"results": [FLUX_PERMISSION_OBJECT_JSON],
1062+
},
1063+
)
10471064
if request.method == "GET" and request.url.path.endswith("/permissions/"):
10481065
return httpx.Response(200, json=[FLUX_ROLE_PERMISSION_JSON])
10491066
if request.method == "POST" and request.url.path.endswith(
10501067
"/permissions/objects/"
10511068
):
10521069
body = json.loads(request.content.decode())
1053-
return httpx.Response(201, json=FLUX_PERMISSION_OBJECT_JSON | body)
1070+
return httpx.Response(201)
10541071
if request.method == "POST" and request.url.path.endswith(
10551072
"/permissions/batch/"
10561073
):
@@ -1083,10 +1100,17 @@ def handler(request: httpx.Request) -> httpx.Response:
10831100
"flux-role-1", content_type="flux-apis"
10841101
)
10851102
assert objects[0].object_key == "api-1"
1103+
assert any(
1104+
method == "GET"
1105+
and "/permissions/flux-api/roles/flux-role-1/permissions/objects/" in url
1106+
and "content_type=flux-apis" in url
1107+
for method, _, url in recorded
1108+
)
10861109

10871110
added = await client.add_flux_permission_object(
10881111
"flux-role-1", FLUX_PERMISSION_OBJECT_JSON
10891112
)
1113+
assert added.content_type == "flux-apis"
10901114
assert added.object_key == "api-1"
10911115

10921116
await client.delete_flux_permission_object(

0 commit comments

Comments
 (0)