diff --git a/README.md b/README.md index c424a14..03d9531 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ This is an autogenerated python SDK for OpenFGA. It provides a wrapper around th - [Read Assertions](#read-assertions) - [Write Assertions](#write-assertions) - [Retries](#retries) + - [Calling Other Endpoints](#calling-other-endpoints) - [API Endpoints](#api-endpoints) - [Models](#models) - [OpenTelemetry](#opentelemetry) @@ -1260,6 +1261,137 @@ body = [ClientAssertion( response = await fga_client.write_assertions(body, options) ``` +### Calling Other Endpoints + +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). + +This is useful when: +- You want to call a new endpoint that is not yet supported by the SDK +- You are using an earlier version of the SDK that doesn't yet support a particular endpoint +- You have a custom endpoint deployed that extends the OpenFGA API + +In all cases, you initialize the SDK the same way as usual, and then call `raw_request` on the `fga_client` instance. + +```python +from openfga_sdk import ClientConfiguration, OpenFgaClient + +configuration = ClientConfiguration( + api_url=FGA_API_URL, + store_id=FGA_STORE_ID, +) + +async with OpenFgaClient(configuration) as fga_client: + request_body = { + "user": "user:bob", + "action": "custom_action", + "resource": "resource:123", + } + + response = await fga_client.raw_request( + operation_name="CustomEndpoint", + method="POST", + path="/stores/{store_id}/custom-endpoint", + path_params={"store_id": FGA_STORE_ID}, + query_params={"page_size": "20"}, + body=request_body, + headers={"X-Experimental-Feature": "enabled"}, + ) +``` + +#### Example: Calling a new "Custom Endpoint" endpoint and handling raw response + +```python +# Get raw response without automatic decoding +raw_response = await fga_client.raw_request( + operation_name="CustomEndpoint", + method="POST", + path="/stores/{store_id}/custom-endpoint", + path_params={"store_id": FGA_STORE_ID}, + body={"user": "user:bob", "action": "custom_action"}, +) + +# Access the response data +if raw_response.status == 200: + # Manually decode the response + result = raw_response.json() + if result: + print(f"Response: {result}") + + # You can access fields like headers, status code, etc. from raw_response: + print(f"Status Code: {raw_response.status}") + print(f"Headers: {raw_response.headers}") + print(f"Body as text: {raw_response.text()}") +``` + +#### Example: Calling a new "Custom Endpoint" endpoint and decoding response into a dictionary + +```python +# Get raw response decoded into a dictionary +response = await fga_client.raw_request( + operation_name="CustomEndpoint", + method="POST", + path="/stores/{store_id}/custom-endpoint", + path_params={"store_id": FGA_STORE_ID}, + body={"user": "user:bob", "action": "custom_action"}, +) + +# The response body is automatically parsed as JSON if possible +result = response.json() # Returns dict or None if not JSON + +if result: + print(f"Response: {result}") + # Access fields from the decoded response + if "allowed" in result: + print(f"Allowed: {result['allowed']}") + +print(f"Status Code: {response.status}") +print(f"Headers: {response.headers}") +``` + +#### Example: Calling an existing endpoint with GET + +```python +# Get a list of stores with query parameters +response = await fga_client.raw_request( + operation_name="ListStores", # Required: descriptive name for the operation + method="GET", + path="/stores", + query_params={ + "page_size": 10, + "continuation_token": "eyJwayI6...", + }, +) + +stores = response.json() +print("Stores:", stores) +``` + +#### Example: Using Path Parameters + +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: + +```python +# Using explicit path parameters +response = await fga_client.raw_request( + operation_name="ReadAuthorizationModel", # Required: descriptive name for the operation + method="GET", + path="/stores/{store_id}/authorization-models/{model_id}", + path_params={ + "store_id": "your-store-id", + "model_id": "your-model-id", + }, +) + +# Using automatic store_id substitution +response = await fga_client.raw_request( + operation_name="ReadAuthorizationModel", # Required: descriptive name for the operation + method="GET", + path="/stores/{store_id}/authorization-models/{model_id}", + path_params={ + "model_id": "your-model-id", + }, +) +``` ### Retries diff --git a/openfga_sdk/api_client.py b/openfga_sdk/api_client.py index d4cc632..68cd5d2 100644 --- a/openfga_sdk/api_client.py +++ b/openfga_sdk/api_client.py @@ -420,7 +420,7 @@ async def __call_api( if _return_http_data_only: return return_data else: - return (return_data, response_data.status, response_data.headers) + return (return_data, response_data.status, response_data.getheaders()) def _parse_retry_after_header(self, headers) -> int: retry_after_header = headers.get("retry-after") diff --git a/openfga_sdk/client/client.py b/openfga_sdk/client/client.py index aba8943..9eba189 100644 --- a/openfga_sdk/client/client.py +++ b/openfga_sdk/client/client.py @@ -1,6 +1,10 @@ import asyncio +import json +import urllib.parse import uuid +from typing import Any + from openfga_sdk.api.open_fga_api import OpenFgaApi from openfga_sdk.api_client import ApiClient from openfga_sdk.client.configuration import ClientConfiguration @@ -25,6 +29,7 @@ from openfga_sdk.client.models.list_objects_request import ClientListObjectsRequest from openfga_sdk.client.models.list_relations_request import ClientListRelationsRequest from openfga_sdk.client.models.list_users_request import ClientListUsersRequest +from openfga_sdk.client.models.raw_response import RawResponse from openfga_sdk.client.models.read_changes_request import ClientReadChangesRequest from openfga_sdk.client.models.tuple import ClientTuple, convert_tuple_keys from openfga_sdk.client.models.write_request import ClientWriteRequest @@ -67,6 +72,10 @@ WriteAuthorizationModelRequest, ) from openfga_sdk.models.write_request import WriteRequest +from openfga_sdk.rest import RESTResponse +from openfga_sdk.telemetry.attributes import ( + TelemetryAttributes, +) from openfga_sdk.validation import is_well_formed_ulid_string @@ -1096,3 +1105,164 @@ def map_to_assertion(client_assertion: ClientAssertion): authorization_model_id, api_request_body, **kwargs ) return api_response + + ####################### + # Raw Request + ####################### + async def raw_request( + self, + method: str, + path: str, + query_params: dict[str, str | int | list[str | int]] | None = None, + path_params: dict[str, str] | None = None, + headers: dict[str, str] | None = None, + body: dict[str, Any] | list[Any] | str | bytes | None = None, + operation_name: str | None = None, + options: dict[str, int | str | dict[str, int | str]] | None = None, + ) -> RawResponse: + """ + Make a raw HTTP request to any OpenFGA API endpoint. + + :param method: HTTP method (GET, POST, PUT, DELETE, PATCH, etc.) + :param path: API endpoint path (e.g., "/stores/{store_id}/check" or "/stores") + :param query_params: Optional query parameters as a dictionary + :param path_params: Optional path parameters to replace placeholders in path + (e.g., {"store_id": "abc", "model_id": "xyz"}) + :param headers: Optional request headers (will be merged with default headers) + :param body: Optional request body (dict/list will be JSON serialized, str/bytes sent as-is) + :param operation_name: Required operation name for telemetry/logging (e.g., "Check", "Write", "CustomEndpoint") + :param options: Optional request options: + - headers: Additional headers (merged with headers parameter) + - retry_params: Override retry parameters for this request + - authorization_model_id: Not used in raw_request, but kept for consistency + :return: RawResponse object with status, headers, and body + :raises FgaValidationException: If path contains {store_id} but store_id is not configured + :raises ApiException: For HTTP errors (with SDK error handling applied) + """ + + request_headers = dict(headers) if headers else {} + if options and options.get("headers"): + request_headers.update(options["headers"]) + + if not operation_name: + raise FgaValidationException("operation_name is required for raw_request") + + resource_path = path + path_params_dict = dict(path_params) if path_params else {} + + if "{store_id}" in resource_path and "store_id" not in path_params_dict: + store_id = self.get_store_id() + if store_id is None or store_id == "": + raise FgaValidationException( + "Path contains {store_id} but store_id is not configured. " + "Set store_id in ClientConfiguration, use set_store_id(), or provide it in path_params." + ) + path_params_dict["store_id"] = store_id + + for param_name, param_value in path_params_dict.items(): + placeholder = f"{{{param_name}}}" + if placeholder in resource_path: + encoded_value = urllib.parse.quote(str(param_value), safe="") + resource_path = resource_path.replace(placeholder, encoded_value) + if "{" in resource_path or "}" in resource_path: + raise FgaValidationException( + f"Not all path parameters were provided for path: {path}" + ) + + query_params_list = [] + if query_params: + for key, value in query_params.items(): + if value is None: + continue + if isinstance(value, list): + for item in value: + if item is not None: + query_params_list.append((key, str(item))) + continue + query_params_list.append((key, str(value))) + + body_params = body + if "Content-Type" not in request_headers: + if isinstance(body, (dict, list)) or body is None: + request_headers["Content-Type"] = "application/json" + elif isinstance(body, str): + request_headers["Content-Type"] = "text/plain" + elif isinstance(body, bytes): + request_headers["Content-Type"] = "application/octet-stream" + else: + request_headers["Content-Type"] = "application/json" + + retry_params = None + if options and options.get("retry_params"): + retry_params = options["retry_params"] + if "Accept" not in request_headers: + request_headers["Accept"] = "application/json" + + auth_headers = dict(request_headers) if request_headers else {} + await self._api_client.update_params_for_auth( + auth_headers, + query_params_list, + auth_settings=[], + oauth2_client=self._api._oauth2_client, + ) + + telemetry_attributes = None + if operation_name: + telemetry_attributes = { + TelemetryAttributes.fga_client_request_method: operation_name.lower(), + } + if self.get_store_id(): + telemetry_attributes[ + TelemetryAttributes.fga_client_request_store_id + ] = self.get_store_id() + + _, http_status, http_headers = await self._api_client.call_api( + resource_path=resource_path, + method=method.upper(), + query_params=query_params_list if query_params_list else None, + header_params=auth_headers if auth_headers else None, + body=body_params, + response_types_map={}, + auth_settings=[], + _return_http_data_only=False, + _preload_content=True, + _retry_params=retry_params, + _oauth2_client=self._api._oauth2_client, + _telemetry_attributes=telemetry_attributes, + ) + rest_response: RESTResponse | None = getattr( + self._api_client, "last_response", None + ) + + if rest_response is None: + raise RuntimeError( + f"Failed to get response from API client for {method.upper()} " + f"request to '{resource_path}'" + f"{f' (operation: {operation_name})' if operation_name else ''}. " + "This may indicate an internal SDK error, network problem, or client configuration issue." + ) + + response_body: bytes | str | dict[str, Any] | None = None + if rest_response.data is not None: + if isinstance(rest_response.data, str): + try: + response_body = json.loads(rest_response.data) + except (json.JSONDecodeError, ValueError): + response_body = rest_response.data + elif isinstance(rest_response.data, bytes): + try: + decoded = rest_response.data.decode("utf-8") + try: + response_body = json.loads(decoded) + except (json.JSONDecodeError, ValueError): + response_body = decoded + except UnicodeDecodeError: + response_body = rest_response.data + else: + response_body = rest_response.data + + return RawResponse( + status=http_status, + headers=http_headers if http_headers else {}, + body=response_body, + ) diff --git a/openfga_sdk/client/models/__init__.py b/openfga_sdk/client/models/__init__.py index 528d527..2d3cde1 100644 --- a/openfga_sdk/client/models/__init__.py +++ b/openfga_sdk/client/models/__init__.py @@ -12,6 +12,7 @@ from openfga_sdk.client.models.expand_request import ClientExpandRequest from openfga_sdk.client.models.list_objects_request import ClientListObjectsRequest from openfga_sdk.client.models.list_relations_request import ClientListRelationsRequest +from openfga_sdk.client.models.raw_response import RawResponse from openfga_sdk.client.models.read_changes_request import ClientReadChangesRequest from openfga_sdk.client.models.tuple import ClientTuple from openfga_sdk.client.models.write_conflict_opts import ( @@ -45,4 +46,5 @@ "ClientWriteRequestOnMissingDeletes", "ConflictOptions", "ClientWriteOptions", + "RawResponse", ] diff --git a/openfga_sdk/client/models/raw_response.py b/openfga_sdk/client/models/raw_response.py new file mode 100644 index 0000000..e369088 --- /dev/null +++ b/openfga_sdk/client/models/raw_response.py @@ -0,0 +1,90 @@ +""" +Raw response wrapper for raw_request method. + +This module provides a simple response wrapper for raw HTTP requests +made through the SDK's raw_request method. +""" + +from dataclasses import dataclass +from typing import Any + + +@dataclass +class RawResponse: + """ + Response wrapper for raw HTTP requests. + + This class provides a simple interface to access the response + from a raw_request call, including status code, headers, and body. + + The body is automatically parsed as JSON if possible, otherwise + it's returned as a string or bytes. + """ + + status: int + """HTTP status code""" + + headers: dict[str, str] + """Response headers as a dictionary""" + + body: bytes | str | dict[str, Any] | None = None + """Response body (already parsed as dict if JSON, otherwise str or bytes)""" + + def json(self) -> dict[str, Any] | None: + """ + Return the response body as a JSON dictionary. + + If the body is already a dict (parsed JSON), returns it directly. + If the body is a string or bytes, attempts to parse it as JSON. + Returns None if body is None or cannot be parsed. + + :return: Parsed JSON dictionary or None + """ + if self.body is None: + return None + + if isinstance(self.body, dict): + return self.body + + if isinstance(self.body, str): + import json + + try: + return json.loads(self.body) + except (json.JSONDecodeError, ValueError): + return None + + if isinstance(self.body, bytes): + import json + + try: + return json.loads(self.body.decode("utf-8")) + except (json.JSONDecodeError, ValueError, UnicodeDecodeError): + return None + + return None + + def text(self) -> str | None: + """ + Return the response body as a string. + + :return: Response body as string or None + """ + if self.body is None: + return None + + if isinstance(self.body, str): + return self.body + + if isinstance(self.body, bytes): + try: + return self.body.decode("utf-8") + except UnicodeDecodeError: + return self.body.decode("utf-8", errors="replace") + + if isinstance(self.body, dict): + import json + + return json.dumps(self.body) + + return str(self.body) diff --git a/openfga_sdk/sync/api_client.py b/openfga_sdk/sync/api_client.py index 7990e25..dfcdff9 100644 --- a/openfga_sdk/sync/api_client.py +++ b/openfga_sdk/sync/api_client.py @@ -420,7 +420,7 @@ def __call_api( if _return_http_data_only: return return_data else: - return (return_data, response_data.status, response_data.headers) + return (return_data, response_data.status, response_data.getheaders()) def _parse_retry_after_header(self, headers) -> int: retry_after_header = headers.get("retry-after") diff --git a/openfga_sdk/sync/client/client.py b/openfga_sdk/sync/client/client.py index 1efa33f..18b0a56 100644 --- a/openfga_sdk/sync/client/client.py +++ b/openfga_sdk/sync/client/client.py @@ -1,6 +1,9 @@ +import json +import urllib.parse import uuid from concurrent.futures import ThreadPoolExecutor +from typing import Any from openfga_sdk.client.configuration import ClientConfiguration from openfga_sdk.client.models.assertion import ClientAssertion @@ -24,6 +27,7 @@ from openfga_sdk.client.models.list_objects_request import ClientListObjectsRequest from openfga_sdk.client.models.list_relations_request import ClientListRelationsRequest from openfga_sdk.client.models.list_users_request import ClientListUsersRequest +from openfga_sdk.client.models.raw_response import RawResponse from openfga_sdk.client.models.read_changes_request import ClientReadChangesRequest from openfga_sdk.client.models.tuple import ClientTuple, convert_tuple_keys from openfga_sdk.client.models.write_request import ClientWriteRequest @@ -68,6 +72,10 @@ from openfga_sdk.models.write_request import WriteRequest from openfga_sdk.sync.api_client import ApiClient from openfga_sdk.sync.open_fga_api import OpenFgaApi +from openfga_sdk.sync.rest import RESTResponse +from openfga_sdk.telemetry.attributes import ( + TelemetryAttributes, +) from openfga_sdk.validation import is_well_formed_ulid_string @@ -1095,3 +1103,166 @@ def map_to_assertion(client_assertion: ClientAssertion) -> Assertion: authorization_model_id, api_request_body, **kwargs ) return api_response + + ####################### + # Raw Request + ####################### + def raw_request( + self, + method: str, + path: str, + query_params: dict[str, str | int | list[str | int]] | None = None, + path_params: dict[str, str] | None = None, + headers: dict[str, str] | None = None, + body: dict[str, Any] | list[Any] | str | bytes | None = None, + operation_name: str | None = None, + options: dict[str, int | str | dict[str, int | str]] | None = None, + ) -> RawResponse: + """ + Make a raw HTTP request to any OpenFGA API endpoint. + + :param method: HTTP method (GET, POST, PUT, DELETE, PATCH, etc.) + :param path: API endpoint path (e.g., "/stores/{store_id}/check" or "/stores") + :param query_params: Optional query parameters as a dictionary + :param path_params: Optional path parameters to replace placeholders in path + (e.g., {"store_id": "abc", "model_id": "xyz"}) + :param headers: Optional request headers (will be merged with default headers) + :param body: Optional request body (dict/list will be JSON serialized, str/bytes sent as-is) + :param operation_name: Required operation name for telemetry/logging (e.g., "Check", "Write", "CustomEndpoint") + :param options: Optional request options: + - headers: Additional headers (merged with headers parameter) + - retry_params: Override retry parameters for this request + - authorization_model_id: Not used in raw_request, but kept for consistency + :return: RawResponse object with status, headers, and body + :raises FgaValidationException: If path contains {store_id} but store_id is not configured + :raises ApiException: For HTTP errors (with SDK error handling applied) + """ + + request_headers = dict(headers) if headers else {} + if options and options.get("headers"): + request_headers.update(options["headers"]) + + if not operation_name: + raise FgaValidationException("operation_name is required for raw_request") + + resource_path = path + path_params_dict = dict(path_params) if path_params else {} + + if "{store_id}" in resource_path and "store_id" not in path_params_dict: + store_id = self.get_store_id() + if store_id is None or store_id == "": + raise FgaValidationException( + "Path contains {store_id} but store_id is not configured. " + "Set store_id in ClientConfiguration, use set_store_id(), or provide it in path_params." + ) + path_params_dict["store_id"] = store_id + + for param_name, param_value in path_params_dict.items(): + placeholder = f"{{{param_name}}}" + if placeholder in resource_path: + encoded_value = urllib.parse.quote(str(param_value), safe="") + resource_path = resource_path.replace(placeholder, encoded_value) + + if "{" in resource_path or "}" in resource_path: + raise FgaValidationException( + f"Not all path parameters were provided for path: {path}" + ) + + query_params_list = [] + if query_params: + for key, value in query_params.items(): + if value is None: + continue + if isinstance(value, list): + for item in value: + if item is not None: + query_params_list.append((key, str(item))) + continue + query_params_list.append((key, str(value))) + + body_params = body + if "Content-Type" not in request_headers: + if isinstance(body, (dict, list)) or body is None: + request_headers["Content-Type"] = "application/json" + elif isinstance(body, str): + request_headers["Content-Type"] = "text/plain" + elif isinstance(body, bytes): + request_headers["Content-Type"] = "application/octet-stream" + else: + request_headers["Content-Type"] = "application/json" + + retry_params = None + if options and options.get("retry_params"): + retry_params = options["retry_params"] + if "Accept" not in request_headers: + request_headers["Accept"] = "application/json" + + auth_headers = dict(request_headers) if request_headers else {} + self._api_client.update_params_for_auth( + auth_headers, + query_params_list, + auth_settings=[], + oauth2_client=self._api._oauth2_client, + ) + + telemetry_attributes = None + if operation_name: + telemetry_attributes = { + TelemetryAttributes.fga_client_request_method: operation_name.lower(), + } + if self.get_store_id(): + telemetry_attributes[ + TelemetryAttributes.fga_client_request_store_id + ] = self.get_store_id() + + _, http_status, http_headers = self._api_client.call_api( + resource_path=resource_path, + method=method.upper(), + query_params=query_params_list if query_params_list else None, + header_params=auth_headers if auth_headers else None, + body=body_params, + response_types_map={}, + auth_settings=[], + _return_http_data_only=False, + _preload_content=True, + _retry_params=retry_params, + _oauth2_client=self._api._oauth2_client, + _telemetry_attributes=telemetry_attributes, + ) + + rest_response: RESTResponse | None = getattr( + self._api_client, "last_response", None + ) + + if rest_response is None: + raise RuntimeError( + f"Failed to get response from API client for {method.upper()} " + f"request to '{resource_path}'" + f"{f' (operation: {operation_name})' if operation_name else ''}. " + "This may indicate an internal SDK error, network problem, or client configuration issue." + ) + + response_body: bytes | str | dict[str, Any] | None = None + if rest_response.data is not None: + if isinstance(rest_response.data, str): + try: + response_body = json.loads(rest_response.data) + except (json.JSONDecodeError, ValueError): + response_body = rest_response.data + elif isinstance(rest_response.data, bytes): + try: + decoded = rest_response.data.decode("utf-8") + try: + response_body = json.loads(decoded) + except (json.JSONDecodeError, ValueError): + response_body = decoded + except UnicodeDecodeError: + response_body = rest_response.data + else: + response_body = rest_response.data + + return RawResponse( + status=http_status, + headers=http_headers if http_headers else {}, + body=response_body, + ) diff --git a/openfga_sdk/telemetry/attributes.py b/openfga_sdk/telemetry/attributes.py index ddda43f..e7b10dd 100644 --- a/openfga_sdk/telemetry/attributes.py +++ b/openfga_sdk/telemetry/attributes.py @@ -295,7 +295,7 @@ def fromResponse( response.status ) - if response.body is not None: + if response.body is not None and isinstance(response.body, dict): response_model_id = response.body.get( "openfga-authorization-model-id" ) or response.body.get("openfga_authorization_model_id") diff --git a/test/client/client_test.py b/test/client/client_test.py index 947134d..52f8b97 100644 --- a/test/client/client_test.py +++ b/test/client/client_test.py @@ -20,6 +20,7 @@ from openfga_sdk.client.models.list_objects_request import ClientListObjectsRequest from openfga_sdk.client.models.list_relations_request import ClientListRelationsRequest from openfga_sdk.client.models.list_users_request import ClientListUsersRequest +from openfga_sdk.client.models.raw_response import RawResponse from openfga_sdk.client.models.read_changes_request import ClientReadChangesRequest from openfga_sdk.client.models.tuple import ClientTuple from openfga_sdk.client.models.write_request import ClientWriteRequest @@ -3906,12 +3907,17 @@ def client_configuration(): ) -class TestClientConfigurationHeaders: +class TestClientConfigurationHeaders(IsolatedAsyncioTestCase): """Tests for ClientConfiguration headers parameter""" - def test_client_configuration_headers_default_none(self, client_configuration): + def setUp(self): + self.configuration = ClientConfiguration( + api_url="http://api.fga.example", + ) + + def test_client_configuration_headers_default_none(self): """Test that headers default to an empty dict in ClientConfiguration""" - assert client_configuration.headers == {} + assert self.configuration.headers == {} def test_client_configuration_headers_initialization_with_dict(self): """Test initializing ClientConfiguration with headers""" @@ -3936,11 +3942,11 @@ def test_client_configuration_headers_initialization_with_none(self): ) assert config.headers == {} - def test_client_configuration_headers_setter(self, client_configuration): + def test_client_configuration_headers_setter(self): """Test setting headers via property setter""" headers = {"X-Test": "test-value"} - client_configuration.headers = headers - assert client_configuration.headers == headers + self.configuration.headers = headers + assert self.configuration.headers == headers def test_client_configuration_headers_with_authorization_model_id(self): """Test ClientConfiguration with headers and authorization_model_id""" @@ -4165,3 +4171,325 @@ async def test_write_with_conflict_options_both(self, mock_request): _preload_content=ANY, _request_timeout=None, ) + + @patch.object(rest.RESTClientObject, "request") + @pytest.mark.asyncio + async def test_raw_request_post_with_body(self, mock_request): + """Test case for raw_request + + Make a raw POST request with JSON body + """ + response_body = '{"result": "success", "data": {"id": "123"}}' + mock_request.return_value = mock_response(response_body, 200) + + configuration = self.configuration + configuration.store_id = store_id + async with OpenFgaClient(configuration) as api_client: + response = await api_client.raw_request( + operation_name="CustomEndpoint", + method="POST", + path="/stores/{store_id}/custom-endpoint", + body={"user": "user:bob", "action": "custom_action"}, + query_params={"page_size": "20"}, + headers={"X-Experimental-Feature": "enabled"}, + ) + + self.assertIsInstance(response, RawResponse) + self.assertEqual(response.status, 200) + self.assertIsNotNone(response.headers) + self.assertIsNotNone(response.body) + self.assertIsInstance(response.body, dict) + self.assertEqual(response.body["result"], "success") + + # Verify response helper methods + json_data = response.json() + self.assertIsNotNone(json_data) + self.assertEqual(json_data["result"], "success") + + text_data = response.text() + self.assertIsNotNone(text_data) + self.assertIn("success", text_data) + + # Verify the API was called with correct parameters + mock_request.assert_called_once_with( + "POST", + "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/custom-endpoint", + query_params=[("page_size", "20")], + headers=ANY, + post_params=None, + body={"user": "user:bob", "action": "custom_action"}, + _preload_content=True, + _request_timeout=None, + ) + await api_client.close() + + @patch.object(rest.RESTClientObject, "request") + @pytest.mark.asyncio + async def test_raw_request_get_with_query_params(self, mock_request): + """Test case for raw_request + + Make a raw GET request with query parameters + """ + response_body = ( + '{"stores": [{"id": "01YCP46JKYM8FJCQ37NMBYHE5X", "name": "store1"}]}' + ) + mock_request.return_value = mock_response(response_body, 200) + + configuration = self.configuration + async with OpenFgaClient(configuration) as api_client: + response = await api_client.raw_request( + operation_name="ListStores", + method="GET", + path="/stores", + query_params={ + "page_size": 10, + "continuation_token": "eyJwayI6...", + }, + ) + + self.assertIsInstance(response, RawResponse) + self.assertEqual(response.status, 200) + self.assertIsNotNone(response.body) + self.assertIsInstance(response.body, dict) + self.assertIn("stores", response.body) + + # Verify the API was called with correct parameters + mock_request.assert_called_once_with( + "GET", + "http://api.fga.example/stores", + query_params=[ + ("page_size", "10"), + ("continuation_token", "eyJwayI6..."), + ], + headers=ANY, + post_params=None, + body=None, + _preload_content=True, + _request_timeout=None, + ) + await api_client.close() + + @patch.object(rest.RESTClientObject, "request") + @pytest.mark.asyncio + async def test_raw_request_with_path_params(self, mock_request): + """Test case for raw_request + + Make a raw request with path parameters + """ + response_body = '{"authorization_model": {"id": "01G5JAVJ41T49E9TT3SKVS7X1J"}}' + mock_request.return_value = mock_response(response_body, 200) + + configuration = self.configuration + configuration.store_id = store_id + async with OpenFgaClient(configuration) as api_client: + response = await api_client.raw_request( + operation_name="ReadAuthorizationModel", + method="GET", + path="/stores/{store_id}/authorization-models/{model_id}", + path_params={"model_id": "01G5JAVJ41T49E9TT3SKVS7X1J"}, + ) + + self.assertIsInstance(response, RawResponse) + self.assertEqual(response.status, 200) + self.assertIsNotNone(response.body) + + # Verify the API was called with correct path (store_id auto-substituted) + mock_request.assert_called_once_with( + "GET", + "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/authorization-models/01G5JAVJ41T49E9TT3SKVS7X1J", + query_params=None, + headers=ANY, + post_params=None, + body=None, + _preload_content=True, + _request_timeout=None, + ) + await api_client.close() + + @patch.object(rest.RESTClientObject, "request") + @pytest.mark.asyncio + async def test_raw_request_auto_store_id_substitution(self, mock_request): + """Test case for raw_request + + Test automatic store_id substitution when not provided in path_params + """ + response_body = '{"id": "01YCP46JKYM8FJCQ37NMBYHE5X", "name": "store1"}' + mock_request.return_value = mock_response(response_body, 200) + + configuration = self.configuration + configuration.store_id = store_id + async with OpenFgaClient(configuration) as api_client: + response = await api_client.raw_request( + operation_name="GetStore", + method="GET", + path="/stores/{store_id}", + ) + + self.assertIsInstance(response, RawResponse) + self.assertEqual(response.status, 200) + + # Verify store_id was automatically substituted + mock_request.assert_called_once_with( + "GET", + "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X", + query_params=None, + headers=ANY, + post_params=None, + body=None, + _preload_content=True, + _request_timeout=None, + ) + await api_client.close() + + @pytest.mark.asyncio + async def test_raw_request_missing_operation_name(self): + """Test case for raw_request + + Test that operation_name is required + """ + configuration = self.configuration + configuration.store_id = store_id + async with OpenFgaClient(configuration) as api_client: + with self.assertRaises(FgaValidationException) as error: + await api_client.raw_request( + method="GET", + path="/stores", + ) + self.assertIn("operation_name is required", str(error.exception)) + await api_client.close() + + @pytest.mark.asyncio + async def test_raw_request_missing_store_id(self): + """Test case for raw_request + + Test that store_id is required when path contains {store_id} + """ + configuration = self.configuration + # Don't set store_id + async with OpenFgaClient(configuration) as api_client: + with self.assertRaises(FgaValidationException) as error: + await api_client.raw_request( + operation_name="GetStore", + method="GET", + path="/stores/{store_id}", + ) + self.assertIn("store_id is not configured", str(error.exception)) + await api_client.close() + + @pytest.mark.asyncio + async def test_raw_request_missing_path_params(self): + """Test case for raw_request + + Test that all path parameters must be provided + """ + configuration = self.configuration + configuration.store_id = store_id + async with OpenFgaClient(configuration) as api_client: + with self.assertRaises(FgaValidationException) as error: + await api_client.raw_request( + operation_name="ReadAuthorizationModel", + method="GET", + path="/stores/{store_id}/authorization-models/{model_id}", + # Missing model_id in path_params + ) + self.assertIn("Not all path parameters were provided", str(error.exception)) + await api_client.close() + + @patch.object(rest.RESTClientObject, "request") + @pytest.mark.asyncio + async def test_raw_request_with_list_query_params(self, mock_request): + """Test case for raw_request + + Test query parameters with list values + """ + response_body = '{"results": []}' + mock_request.return_value = mock_response(response_body, 200) + + configuration = self.configuration + async with OpenFgaClient(configuration) as api_client: + response = await api_client.raw_request( + operation_name="ListStores", + method="GET", + path="/stores", + query_params={"ids": ["id1", "id2", "id3"]}, + ) + + self.assertIsInstance(response, RawResponse) + self.assertEqual(response.status, 200) + + # Verify list query params are expanded + mock_request.assert_called_once_with( + "GET", + "http://api.fga.example/stores", + query_params=[("ids", "id1"), ("ids", "id2"), ("ids", "id3")], + headers=ANY, + post_params=None, + body=None, + _preload_content=True, + _request_timeout=None, + ) + await api_client.close() + + @patch.object(rest.RESTClientObject, "request") + @pytest.mark.asyncio + async def test_raw_request_default_headers(self, mock_request): + """Test case for raw_request + + Test that default headers (Content-Type, Accept) are set + """ + response_body = '{"result": "success"}' + mock_request.return_value = mock_response(response_body, 200) + + configuration = self.configuration + configuration.store_id = store_id + async with OpenFgaClient(configuration) as api_client: + response = await api_client.raw_request( + operation_name="CustomEndpoint", + method="POST", + path="/stores/{store_id}/custom-endpoint", + body={"test": "data"}, + ) + + self.assertIsInstance(response, RawResponse) + self.assertEqual(response.status, 200) + + # Verify default headers are set + call_args = mock_request.call_args + headers = call_args[1]["headers"] + self.assertEqual(headers.get("Content-Type"), "application/json") + self.assertEqual(headers.get("Accept"), "application/json") + await api_client.close() + + @patch.object(rest.RESTClientObject, "request") + @pytest.mark.asyncio + async def test_raw_request_url_encoded_path_params(self, mock_request): + """Test case for raw_request + + Test that path parameters are URL encoded + """ + response_body = '{"result": "success"}' + mock_request.return_value = mock_response(response_body, 200) + + configuration = self.configuration + configuration.store_id = store_id + async with OpenFgaClient(configuration) as api_client: + response = await api_client.raw_request( + operation_name="CustomEndpoint", + method="GET", + path="/stores/{store_id}/custom/{param}", + path_params={"param": "value with spaces & special chars"}, + ) + + self.assertIsInstance(response, RawResponse) + + mock_request.assert_called_once_with( + "GET", + "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/custom/value%20with%20spaces%20%26%20special%20chars", + query_params=None, + headers=ANY, + post_params=None, + body=None, + _preload_content=True, + _request_timeout=None, + ) + await api_client.close() diff --git a/test/sync/client/client_test.py b/test/sync/client/client_test.py index dbffbd0..d3fe576 100644 --- a/test/sync/client/client_test.py +++ b/test/sync/client/client_test.py @@ -3,7 +3,7 @@ import uuid from datetime import datetime -from unittest import IsolatedAsyncioTestCase +from unittest import IsolatedAsyncioTestCase, TestCase from unittest.mock import ANY, patch import pytest @@ -18,6 +18,7 @@ from openfga_sdk.client.models.list_objects_request import ClientListObjectsRequest from openfga_sdk.client.models.list_relations_request import ClientListRelationsRequest from openfga_sdk.client.models.list_users_request import ClientListUsersRequest +from openfga_sdk.client.models.raw_response import RawResponse from openfga_sdk.client.models.read_changes_request import ClientReadChangesRequest from openfga_sdk.client.models.tuple import ClientTuple from openfga_sdk.client.models.write_request import ClientWriteRequest @@ -3885,12 +3886,19 @@ def client_configuration(): ) -class TestSyncClientConfigurationHeaders: +class TestSyncClientConfigurationHeaders(TestCase): """Tests for ClientConfiguration headers parameter in sync client""" - def test_sync_client_configuration_headers_default_none(self, client_configuration): + def setUp(self): + from openfga_sdk.client.configuration import ClientConfiguration + + self.configuration = ClientConfiguration( + api_url="http://api.fga.example", + ) + + def test_sync_client_configuration_headers_default_none(self): """Test that headers default to an empty dict in ClientConfiguration""" - assert client_configuration.headers == {} + assert self.configuration.headers == {} def test_sync_client_configuration_headers_initialization_with_dict(self): """Test initializing ClientConfiguration with headers""" @@ -3919,11 +3927,11 @@ def test_sync_client_configuration_headers_initialization_with_none(self): ) assert config.headers == {} - def test_sync_client_configuration_headers_setter(self, client_configuration): + def test_sync_client_configuration_headers_setter(self): """Test setting headers via property setter""" headers = {"X-Test": "test-value"} - client_configuration.headers = headers - assert client_configuration.headers == headers + self.configuration.headers = headers + assert self.configuration.headers == headers def test_sync_client_configuration_headers_with_authorization_model_id(self): """Test ClientConfiguration with headers and authorization_model_id""" @@ -4146,3 +4154,313 @@ def test_sync_write_with_conflict_options_both(self, mock_request): _preload_content=ANY, _request_timeout=None, ) + + @patch.object(rest.RESTClientObject, "request") + def test_raw_request_post_with_body(self, mock_request): + """Test case for raw_request + + Make a raw POST request with JSON body + """ + response_body = '{"result": "success", "data": {"id": "123"}}' + mock_request.return_value = mock_response(response_body, 200) + + configuration = self.configuration + configuration.store_id = store_id + with OpenFgaClient(configuration) as api_client: + response = api_client.raw_request( + operation_name="CustomEndpoint", + method="POST", + path="/stores/{store_id}/custom-endpoint", + body={"user": "user:bob", "action": "custom_action"}, + query_params={"page_size": "20"}, + headers={"X-Experimental-Feature": "enabled"}, + ) + + self.assertIsInstance(response, RawResponse) + self.assertEqual(response.status, 200) + self.assertIsNotNone(response.headers) + self.assertIsNotNone(response.body) + self.assertIsInstance(response.body, dict) + self.assertEqual(response.body["result"], "success") + + # Verify response helper methods + json_data = response.json() + self.assertIsNotNone(json_data) + self.assertEqual(json_data["result"], "success") + + text_data = response.text() + self.assertIsNotNone(text_data) + self.assertIn("success", text_data) + + # Verify the API was called with correct parameters + mock_request.assert_called_once_with( + "POST", + "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/custom-endpoint", + query_params=[("page_size", "20")], + headers=ANY, + post_params=None, + body={"user": "user:bob", "action": "custom_action"}, + _preload_content=True, + _request_timeout=None, + ) + api_client.close() + + @patch.object(rest.RESTClientObject, "request") + def test_raw_request_get_with_query_params(self, mock_request): + """Test case for raw_request + + Make a raw GET request with query parameters + """ + response_body = ( + '{"stores": [{"id": "01YCP46JKYM8FJCQ37NMBYHE5X", "name": "store1"}]}' + ) + mock_request.return_value = mock_response(response_body, 200) + + configuration = self.configuration + with OpenFgaClient(configuration) as api_client: + response = api_client.raw_request( + operation_name="ListStores", + method="GET", + path="/stores", + query_params={ + "page_size": 10, + "continuation_token": "eyJwayI6...", + }, + ) + + self.assertIsInstance(response, RawResponse) + self.assertEqual(response.status, 200) + self.assertIsNotNone(response.body) + self.assertIsInstance(response.body, dict) + self.assertIn("stores", response.body) + + # Verify the API was called with correct parameters + mock_request.assert_called_once_with( + "GET", + "http://api.fga.example/stores", + query_params=[ + ("page_size", "10"), + ("continuation_token", "eyJwayI6..."), + ], + headers=ANY, + post_params=None, + body=None, + _preload_content=True, + _request_timeout=None, + ) + api_client.close() + + @patch.object(rest.RESTClientObject, "request") + def test_raw_request_with_path_params(self, mock_request): + """Test case for raw_request + + Make a raw request with path parameters + """ + response_body = '{"authorization_model": {"id": "01G5JAVJ41T49E9TT3SKVS7X1J"}}' + mock_request.return_value = mock_response(response_body, 200) + + configuration = self.configuration + configuration.store_id = store_id + with OpenFgaClient(configuration) as api_client: + response = api_client.raw_request( + operation_name="ReadAuthorizationModel", + method="GET", + path="/stores/{store_id}/authorization-models/{model_id}", + path_params={"model_id": "01G5JAVJ41T49E9TT3SKVS7X1J"}, + ) + + self.assertIsInstance(response, RawResponse) + self.assertEqual(response.status, 200) + self.assertIsNotNone(response.body) + + # Verify the API was called with correct path (store_id auto-substituted) + mock_request.assert_called_once_with( + "GET", + "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/authorization-models/01G5JAVJ41T49E9TT3SKVS7X1J", + query_params=None, + headers=ANY, + post_params=None, + body=None, + _preload_content=True, + _request_timeout=None, + ) + api_client.close() + + @patch.object(rest.RESTClientObject, "request") + def test_raw_request_auto_store_id_substitution(self, mock_request): + """Test case for raw_request + + Test automatic store_id substitution when not provided in path_params + """ + response_body = '{"id": "01YCP46JKYM8FJCQ37NMBYHE5X", "name": "store1"}' + mock_request.return_value = mock_response(response_body, 200) + + configuration = self.configuration + configuration.store_id = store_id + with OpenFgaClient(configuration) as api_client: + response = api_client.raw_request( + operation_name="GetStore", + method="GET", + path="/stores/{store_id}", + ) + + self.assertIsInstance(response, RawResponse) + self.assertEqual(response.status, 200) + + # Verify store_id was automatically substituted + mock_request.assert_called_once_with( + "GET", + "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X", + query_params=None, + headers=ANY, + post_params=None, + body=None, + _preload_content=True, + _request_timeout=None, + ) + api_client.close() + + def test_raw_request_missing_operation_name(self): + """Test case for raw_request + + Test that operation_name is required + """ + configuration = self.configuration + configuration.store_id = store_id + with OpenFgaClient(configuration) as api_client: + with self.assertRaises(FgaValidationException) as error: + api_client.raw_request( + method="GET", + path="/stores", + ) + self.assertIn("operation_name is required", str(error.exception)) + api_client.close() + + def test_raw_request_missing_store_id(self): + """Test case for raw_request + + Test that store_id is required when path contains {store_id} + """ + configuration = self.configuration + # Don't set store_id + with OpenFgaClient(configuration) as api_client: + with self.assertRaises(FgaValidationException) as error: + api_client.raw_request( + operation_name="GetStore", + method="GET", + path="/stores/{store_id}", + ) + self.assertIn("store_id is not configured", str(error.exception)) + api_client.close() + + def test_raw_request_missing_path_params(self): + """Test case for raw_request + + Test that all path parameters must be provided + """ + configuration = self.configuration + configuration.store_id = store_id + with OpenFgaClient(configuration) as api_client: + with self.assertRaises(FgaValidationException) as error: + api_client.raw_request( + operation_name="ReadAuthorizationModel", + method="GET", + path="/stores/{store_id}/authorization-models/{model_id}", + # Missing model_id in path_params + ) + self.assertIn("Not all path parameters were provided", str(error.exception)) + api_client.close() + + @patch.object(rest.RESTClientObject, "request") + def test_raw_request_with_list_query_params(self, mock_request): + """Test case for raw_request + + Test query parameters with list values + """ + response_body = '{"results": []}' + mock_request.return_value = mock_response(response_body, 200) + + configuration = self.configuration + with OpenFgaClient(configuration) as api_client: + response = api_client.raw_request( + operation_name="ListStores", + method="GET", + path="/stores", + query_params={"ids": ["id1", "id2", "id3"]}, + ) + + self.assertIsInstance(response, RawResponse) + self.assertEqual(response.status, 200) + + mock_request.assert_called_once_with( + "GET", + "http://api.fga.example/stores", + query_params=[("ids", "id1"), ("ids", "id2"), ("ids", "id3")], + headers=ANY, + post_params=None, + body=None, + _preload_content=True, + _request_timeout=None, + ) + api_client.close() + + @patch.object(rest.RESTClientObject, "request") + def test_raw_request_default_headers(self, mock_request): + """Test case for raw_request + + Test that default headers (Content-Type, Accept) are set + """ + response_body = '{"result": "success"}' + mock_request.return_value = mock_response(response_body, 200) + + configuration = self.configuration + configuration.store_id = store_id + with OpenFgaClient(configuration) as api_client: + response = api_client.raw_request( + operation_name="CustomEndpoint", + method="POST", + path="/stores/{store_id}/custom-endpoint", + body={"test": "data"}, + ) + + self.assertIsInstance(response, RawResponse) + self.assertEqual(response.status, 200) + + call_args = mock_request.call_args + headers = call_args[1]["headers"] + self.assertEqual(headers.get("Content-Type"), "application/json") + self.assertEqual(headers.get("Accept"), "application/json") + api_client.close() + + @patch.object(rest.RESTClientObject, "request") + def test_raw_request_url_encoded_path_params(self, mock_request): + """Test case for raw_request + + Test that path parameters are URL encoded + """ + response_body = '{"result": "success"}' + mock_request.return_value = mock_response(response_body, 200) + + configuration = self.configuration + configuration.store_id = store_id + with OpenFgaClient(configuration) as api_client: + response = api_client.raw_request( + operation_name="CustomEndpoint", + method="GET", + path="/stores/{store_id}/custom/{param}", + path_params={"param": "value with spaces & special chars"}, + ) + + self.assertIsInstance(response, RawResponse) + + mock_request.assert_called_once_with( + "GET", + "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/custom/value%20with%20spaces%20%26%20special%20chars", + query_params=None, + headers=ANY, + post_params=None, + body=None, + _preload_content=True, + _request_timeout=None, + ) + api_client.close()