Skip to content

Commit f4dec14

Browse files
committed
feat: add raw_request method
1 parent 9a67559 commit f4dec14

File tree

10 files changed

+1177
-3
lines changed

10 files changed

+1177
-3
lines changed

README.md

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ This is an autogenerated python SDK for OpenFGA. It provides a wrapper around th
4848
- [Read Assertions](#read-assertions)
4949
- [Write Assertions](#write-assertions)
5050
- [Retries](#retries)
51+
- [Calling Other Endpoints](#calling-other-endpoints)
5152
- [API Endpoints](#api-endpoints)
5253
- [Models](#models)
5354
- [OpenTelemetry](#opentelemetry)
@@ -1260,6 +1261,137 @@ body = [ClientAssertion(
12601261
response = await fga_client.write_assertions(body, options)
12611262
```
12621263

1264+
### Calling Other Endpoints
1265+
1266+
In certain cases you may want to call other APIs not yet wrapped by the SDK. You can do so by using the `raw_request` method available on the `OpenFgaClient`. The `raw_request` method allows you to make raw HTTP calls to any OpenFGA endpoint by specifying the operation name, HTTP method, path, parameters, body, and headers, while still honoring the client configuration (authentication, telemetry, retries, and error handling).
1267+
1268+
This is useful when:
1269+
- You want to call a new endpoint that is not yet supported by the SDK
1270+
- You are using an earlier version of the SDK that doesn't yet support a particular endpoint
1271+
- You have a custom endpoint deployed that extends the OpenFGA API
1272+
1273+
In all cases, you initialize the SDK the same way as usual, and then call `raw_request` on the `fga_client` instance.
1274+
1275+
```python
1276+
from openfga_sdk import ClientConfiguration, OpenFgaClient
1277+
1278+
configuration = ClientConfiguration(
1279+
api_url=FGA_API_URL,
1280+
store_id=FGA_STORE_ID,
1281+
)
1282+
1283+
async with OpenFgaClient(configuration) as fga_client:
1284+
request_body = {
1285+
"user": "user:bob",
1286+
"action": "custom_action",
1287+
"resource": "resource:123",
1288+
}
1289+
1290+
response = await fga_client.raw_request(
1291+
operation_name="CustomEndpoint",
1292+
method="POST",
1293+
path="/stores/{store_id}/custom-endpoint",
1294+
path_params={"store_id": FGA_STORE_ID},
1295+
query_params={"page_size": "20"},
1296+
body=request_body,
1297+
headers={"X-Experimental-Feature": "enabled"},
1298+
)
1299+
```
1300+
1301+
#### Example: Calling a new "Custom Endpoint" endpoint and handling raw response
1302+
1303+
```python
1304+
# Get raw response without automatic decoding
1305+
raw_response = await fga_client.raw_request(
1306+
operation_name="CustomEndpoint",
1307+
method="POST",
1308+
path="/stores/{store_id}/custom-endpoint",
1309+
path_params={"store_id": FGA_STORE_ID},
1310+
body={"user": "user:bob", "action": "custom_action"},
1311+
)
1312+
1313+
# Access the response data
1314+
if raw_response.status == 200:
1315+
# Manually decode the response
1316+
result = raw_response.json()
1317+
if result:
1318+
print(f"Response: {result}")
1319+
1320+
# You can access fields like headers, status code, etc. from raw_response:
1321+
print(f"Status Code: {raw_response.status}")
1322+
print(f"Headers: {raw_response.headers}")
1323+
print(f"Body as text: {raw_response.text()}")
1324+
```
1325+
1326+
#### Example: Calling a new "Custom Endpoint" endpoint and decoding response into a dictionary
1327+
1328+
```python
1329+
# Get raw response decoded into a dictionary
1330+
response = await fga_client.raw_request(
1331+
operation_name="CustomEndpoint",
1332+
method="POST",
1333+
path="/stores/{store_id}/custom-endpoint",
1334+
path_params={"store_id": FGA_STORE_ID},
1335+
body={"user": "user:bob", "action": "custom_action"},
1336+
)
1337+
1338+
# The response body is automatically parsed as JSON if possible
1339+
result = response.json() # Returns dict or None if not JSON
1340+
1341+
if result:
1342+
print(f"Response: {result}")
1343+
# Access fields from the decoded response
1344+
if "allowed" in result:
1345+
print(f"Allowed: {result['allowed']}")
1346+
1347+
print(f"Status Code: {response.status}")
1348+
print(f"Headers: {response.headers}")
1349+
```
1350+
1351+
#### Example: Calling an existing endpoint with GET
1352+
1353+
```python
1354+
# Get a list of stores with query parameters
1355+
response = await fga_client.raw_request(
1356+
operation_name="ListStores", # Required: descriptive name for the operation
1357+
method="GET",
1358+
path="/stores",
1359+
query_params={
1360+
"page_size": 10,
1361+
"continuation_token": "eyJwayI6...",
1362+
},
1363+
)
1364+
1365+
stores = response.json()
1366+
print("Stores:", stores)
1367+
```
1368+
1369+
#### Example: Using Path Parameters
1370+
1371+
Path parameters are specified in the path using `{param_name}` syntax and are replaced with URL-encoded values from the `path_params` dictionary. If `{store_id}` is present in the path and not provided in `path_params`, it will be automatically replaced with the configured store_id:
1372+
1373+
```python
1374+
# Using explicit path parameters
1375+
response = await fga_client.raw_request(
1376+
operation_name="ReadAuthorizationModel", # Required: descriptive name for the operation
1377+
method="GET",
1378+
path="/stores/{store_id}/authorization-models/{model_id}",
1379+
path_params={
1380+
"store_id": "your-store-id",
1381+
"model_id": "your-model-id",
1382+
},
1383+
)
1384+
1385+
# Using automatic store_id substitution
1386+
response = await fga_client.raw_request(
1387+
operation_name="ReadAuthorizationModel", # Required: descriptive name for the operation
1388+
method="GET",
1389+
path="/stores/{store_id}/authorization-models/{model_id}",
1390+
path_params={
1391+
"model_id": "your-model-id",
1392+
},
1393+
)
1394+
```
12631395

12641396
### Retries
12651397

openfga_sdk/api_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,7 @@ async def __call_api(
420420
if _return_http_data_only:
421421
return return_data
422422
else:
423-
return (return_data, response_data.status, response_data.headers)
423+
return (return_data, response_data.status, response_data.getheaders())
424424

425425
def _parse_retry_after_header(self, headers) -> int:
426426
retry_after_header = headers.get("retry-after")

openfga_sdk/client/client.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import asyncio
22
import uuid
3+
import urllib.parse
4+
from typing import Any
5+
import json
36

47
from openfga_sdk.api.open_fga_api import OpenFgaApi
58
from openfga_sdk.api_client import ApiClient
@@ -33,13 +36,15 @@
3336
construct_write_single_response,
3437
)
3538
from openfga_sdk.client.models.write_transaction_opts import WriteTransactionOpts
39+
from openfga_sdk.client.models.raw_response import RawResponse
3640
from openfga_sdk.constants import (
3741
CLIENT_BULK_REQUEST_ID_HEADER,
3842
CLIENT_MAX_BATCH_SIZE,
3943
CLIENT_MAX_METHOD_PARALLEL_REQUESTS,
4044
CLIENT_METHOD_HEADER,
4145
)
4246
from openfga_sdk.exceptions import (
47+
ApiException,
4348
AuthenticationError,
4449
FgaValidationException,
4550
UnauthorizedException,
@@ -68,6 +73,7 @@
6873
)
6974
from openfga_sdk.models.write_request import WriteRequest
7075
from openfga_sdk.validation import is_well_formed_ulid_string
76+
from openfga_sdk.rest import RESTResponse
7177

7278

7379
def _chuck_array(array, max_size):
@@ -1096,3 +1102,164 @@ def map_to_assertion(client_assertion: ClientAssertion):
10961102
authorization_model_id, api_request_body, **kwargs
10971103
)
10981104
return api_response
1105+
1106+
#######################
1107+
# Raw Request
1108+
#######################
1109+
async def raw_request(
1110+
self,
1111+
method: str,
1112+
path: str,
1113+
query_params: dict[str, str | int | list[str | int]] | None = None,
1114+
path_params: dict[str, str] | None = None,
1115+
headers: dict[str, str] | None = None,
1116+
body: dict[str, Any] | list[Any] | str | bytes | None = None,
1117+
operation_name: str | None = None,
1118+
options: dict[str, int | str | dict[str, int | str]] | None = None,
1119+
) -> RawResponse:
1120+
"""
1121+
Make a raw HTTP request to any OpenFGA API endpoint.
1122+
1123+
:param method: HTTP method (GET, POST, PUT, DELETE, PATCH, etc.)
1124+
:param path: API endpoint path (e.g., "/stores/{store_id}/check" or "/stores")
1125+
:param query_params: Optional query parameters as a dictionary
1126+
:param path_params: Optional path parameters to replace placeholders in path
1127+
(e.g., {"store_id": "abc", "model_id": "xyz"})
1128+
:param headers: Optional request headers (will be merged with default headers)
1129+
:param body: Optional request body (dict/list will be JSON serialized, str/bytes sent as-is)
1130+
:param operation_name: Required operation name for telemetry/logging (e.g., "Check", "Write", "CustomEndpoint")
1131+
:param options: Optional request options:
1132+
- headers: Additional headers (merged with headers parameter)
1133+
- retry_params: Override retry parameters for this request
1134+
- authorization_model_id: Not used in raw_request, but kept for consistency
1135+
:return: RawResponse object with status, headers, and body
1136+
:raises FgaValidationException: If path contains {store_id} but store_id is not configured
1137+
:raises ApiException: For HTTP errors (with SDK error handling applied)
1138+
"""
1139+
1140+
request_headers = dict(headers) if headers else {}
1141+
if options and options.get("headers"):
1142+
request_headers.update(options["headers"])
1143+
1144+
if not operation_name:
1145+
raise FgaValidationException("operation_name is required for raw_request")
1146+
1147+
resource_path = path
1148+
path_params_dict = dict(path_params) if path_params else {}
1149+
1150+
if "{store_id}" in resource_path and "store_id" not in path_params_dict:
1151+
store_id = self.get_store_id()
1152+
if store_id is None or store_id == "":
1153+
raise FgaValidationException(
1154+
"Path contains {store_id} but store_id is not configured. "
1155+
"Set store_id in ClientConfiguration, use set_store_id(), or provide it in path_params."
1156+
)
1157+
path_params_dict["store_id"] = store_id
1158+
1159+
for param_name, param_value in path_params_dict.items():
1160+
placeholder = f"{{{param_name}}}"
1161+
if placeholder in resource_path:
1162+
encoded_value = urllib.parse.quote(str(param_value), safe="")
1163+
resource_path = resource_path.replace(placeholder, encoded_value)
1164+
if "{" in resource_path or "}" in resource_path:
1165+
raise FgaValidationException(
1166+
f"Not all path parameters were provided for path: {path}"
1167+
)
1168+
1169+
query_params_list = []
1170+
if query_params:
1171+
for key, value in query_params.items():
1172+
if value is None:
1173+
continue
1174+
if isinstance(value, list):
1175+
for item in value:
1176+
if item is not None:
1177+
query_params_list.append((key, str(item)))
1178+
continue
1179+
query_params_list.append((key, str(value)))
1180+
1181+
body_params = None
1182+
if body is not None:
1183+
if isinstance(body, (dict, list)):
1184+
body_params = json.dumps(body)
1185+
elif isinstance(body, str):
1186+
body_params = body
1187+
elif isinstance(body, bytes):
1188+
body_params = body
1189+
else:
1190+
body_params = json.dumps(body)
1191+
1192+
retry_params = None
1193+
if options and options.get("retry_params"):
1194+
retry_params = options["retry_params"]
1195+
1196+
if "Content-Type" not in request_headers:
1197+
request_headers["Content-Type"] = "application/json"
1198+
if "Accept" not in request_headers:
1199+
request_headers["Accept"] = "application/json"
1200+
1201+
auth_headers = dict(request_headers) if request_headers else {}
1202+
await self._api_client.update_params_for_auth(
1203+
auth_headers,
1204+
query_params_list,
1205+
auth_settings=[],
1206+
oauth2_client=self._api._oauth2_client,
1207+
)
1208+
1209+
telemetry_attributes = None
1210+
if operation_name:
1211+
from openfga_sdk.telemetry.attributes import TelemetryAttribute, TelemetryAttributes
1212+
telemetry_attributes = {
1213+
TelemetryAttributes.fga_client_request_method: operation_name.lower(),
1214+
}
1215+
if self.get_store_id():
1216+
telemetry_attributes[TelemetryAttributes.fga_client_request_store_id] = self.get_store_id()
1217+
1218+
try:
1219+
_, http_status, http_headers = await self._api_client.call_api(
1220+
resource_path=resource_path,
1221+
method=method.upper(),
1222+
query_params=query_params_list if query_params_list else None,
1223+
header_params=auth_headers if auth_headers else None,
1224+
body=body_params,
1225+
response_types_map={},
1226+
auth_settings=[],
1227+
_return_http_data_only=False,
1228+
_preload_content=True,
1229+
_retry_params=retry_params,
1230+
_oauth2_client=self._api._oauth2_client,
1231+
_telemetry_attributes=telemetry_attributes,
1232+
)
1233+
except ApiException as e:
1234+
raise
1235+
rest_response: RESTResponse | None = getattr(
1236+
self._api_client, "last_response", None
1237+
)
1238+
1239+
if rest_response is None:
1240+
raise RuntimeError("Failed to get response from API client")
1241+
1242+
response_body: bytes | str | dict[str, Any] | None = None
1243+
if rest_response.data is not None:
1244+
if isinstance(rest_response.data, str):
1245+
try:
1246+
response_body = json.loads(rest_response.data)
1247+
except (json.JSONDecodeError, ValueError):
1248+
response_body = rest_response.data
1249+
elif isinstance(rest_response.data, bytes):
1250+
try:
1251+
decoded = rest_response.data.decode("utf-8")
1252+
try:
1253+
response_body = json.loads(decoded)
1254+
except (json.JSONDecodeError, ValueError):
1255+
response_body = decoded
1256+
except UnicodeDecodeError:
1257+
response_body = rest_response.data
1258+
else:
1259+
response_body = rest_response.data
1260+
1261+
return RawResponse(
1262+
status=http_status,
1263+
headers=http_headers if http_headers else {},
1264+
body=response_body,
1265+
)

openfga_sdk/client/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from openfga_sdk.client.models.write_request import ClientWriteRequest
2424
from openfga_sdk.client.models.write_response import ClientWriteResponse
2525
from openfga_sdk.client.models.write_transaction_opts import WriteTransactionOpts
26+
from openfga_sdk.client.models.raw_response import RawResponse
2627

2728

2829
__all__ = [
@@ -45,4 +46,5 @@
4546
"ClientWriteRequestOnMissingDeletes",
4647
"ConflictOptions",
4748
"ClientWriteOptions",
49+
"RawResponse",
4850
]

0 commit comments

Comments
 (0)