diff --git a/.openapi-generator/FILES b/.openapi-generator/FILES index eeac083..86fde12 100644 --- a/.openapi-generator/FILES +++ b/.openapi-generator/FILES @@ -72,6 +72,8 @@ docs/RelationshipCondition.md docs/SourceInfo.md docs/Status.md docs/Store.md +docs/StreamResultOfStreamedListObjectsResponse.md +docs/StreamedListObjectsResponse.md docs/Tuple.md docs/TupleChange.md docs/TupleKey.md @@ -114,6 +116,14 @@ example/opentelemetry/main.py example/opentelemetry/requirements.txt example/opentelemetry/setup.cfg example/opentelemetry/setup.py +example/streamed-list-objects/.env.example +example/streamed-list-objects/.gitignore +example/streamed-list-objects/README.md +example/streamed-list-objects/asynchronous.py +example/streamed-list-objects/requirements.txt +example/streamed-list-objects/setup.cfg +example/streamed-list-objects/setup.py +example/streamed-list-objects/synchronous.py openfga_sdk/__init__.py openfga_sdk/api/__init__.py openfga_sdk/api/open_fga_api.py @@ -203,6 +213,8 @@ openfga_sdk/models/relationship_condition.py openfga_sdk/models/source_info.py openfga_sdk/models/status.py openfga_sdk/models/store.py +openfga_sdk/models/stream_result_of_streamed_list_objects_response.py +openfga_sdk/models/streamed_list_objects_response.py openfga_sdk/models/tuple.py openfga_sdk/models/tuple_change.py openfga_sdk/models/tuple_key.py @@ -255,6 +267,7 @@ test-requirements.txt test/_/configuration_test.py test/_/credentials_test.py test/_/oauth2_test.py +test/_/rest_test.py test/_/validation_test.py test/__init__.py test/api/__init__.py @@ -265,6 +278,7 @@ test/sync/client/__init__.py test/sync/client/client_test.py test/sync/oauth2_test.py test/sync/open_fga_api_test.py +test/sync/rest_test.py test/telemetry/attributes_test.py test/telemetry/configuration_test.py test/telemetry/counters_test.py diff --git a/README.md b/README.md index 9568404..f0b00a7 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ This is an autogenerated python SDK for OpenFGA. It provides a wrapper around th - [Batch Check](#batch-check) - [Expand](#expand) - [List Objects](#list-objects) + - [Streamed List Objects](#streamed-list-objects) - [List Relations](#list-relations) - [List Users](#list-users) - [Assertions](#assertions) @@ -887,6 +888,33 @@ response = await fga_client.list_objects(body) # response.objects = ["document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a"] ``` +#### Streamed List Objects + +List the objects of a particular type a user has access to, using the streaming API. + +[API Documentation](https://openfga.dev/api/service#/Relationship%20Queries/StreamedListObjects) + +```python +# from openfga_sdk import OpenFgaClient +# from openfga_sdk.client.models import ClientListObjectsRequest + +# Initialize the fga_client +# fga_client = OpenFgaClient(configuration) + +results = [] + +documents = ClientListObjectsRequest( + type="document", + relation="writer", + user="user:81684243-9356-4421-8fbf-a4f8d36aa31b", +) + +async for response in fga_client.streamed_list_objects(request): + results.append(response) + +# results = ["document:...", ...] +``` + #### List Relations List the relations a user has on an object. @@ -1066,6 +1094,7 @@ Class | Method | HTTP request | Description *OpenFgaApi* | [**read_authorization_model**](https://github.com/openfga/python-sdk/blob/main/docs/OpenFgaApi.md#read_authorization_model) | **GET** /stores/{store_id}/authorization-models/{id} | Return a particular version of an authorization model *OpenFgaApi* | [**read_authorization_models**](https://github.com/openfga/python-sdk/blob/main/docs/OpenFgaApi.md#read_authorization_models) | **GET** /stores/{store_id}/authorization-models | Return all the authorization models for a particular store *OpenFgaApi* | [**read_changes**](https://github.com/openfga/python-sdk/blob/main/docs/OpenFgaApi.md#read_changes) | **GET** /stores/{store_id}/changes | Return a list of all the tuple changes +*OpenFgaApi* | [**streamed_list_objects**](https://github.com/openfga/python-sdk/blob/main/docs/OpenFgaApi.md#streamed_list_objects) | **POST** /stores/{store_id}/streamed-list-objects | Stream all objects of the given type that the user has a relation with *OpenFgaApi* | [**write**](https://github.com/openfga/python-sdk/blob/main/docs/OpenFgaApi.md#write) | **POST** /stores/{store_id}/write | Add or delete tuples from the store *OpenFgaApi* | [**write_assertions**](https://github.com/openfga/python-sdk/blob/main/docs/OpenFgaApi.md#write_assertions) | **PUT** /stores/{store_id}/assertions/{authorization_model_id} | Upsert assertions for an authorization model ID *OpenFgaApi* | [**write_authorization_model**](https://github.com/openfga/python-sdk/blob/main/docs/OpenFgaApi.md#write_authorization_model) | **POST** /stores/{store_id}/authorization-models | Create a new authorization model @@ -1134,6 +1163,8 @@ Class | Method | HTTP request | Description - [SourceInfo](https://github.com/openfga/python-sdk/blob/main/docs/SourceInfo.md) - [Status](https://github.com/openfga/python-sdk/blob/main/docs/Status.md) - [Store](https://github.com/openfga/python-sdk/blob/main/docs/Store.md) + - [StreamResultOfStreamedListObjectsResponse](https://github.com/openfga/python-sdk/blob/main/docs/StreamResultOfStreamedListObjectsResponse.md) + - [StreamedListObjectsResponse](https://github.com/openfga/python-sdk/blob/main/docs/StreamedListObjectsResponse.md) - [Tuple](https://github.com/openfga/python-sdk/blob/main/docs/Tuple.md) - [TupleChange](https://github.com/openfga/python-sdk/blob/main/docs/TupleChange.md) - [TupleKey](https://github.com/openfga/python-sdk/blob/main/docs/TupleKey.md) @@ -1177,7 +1208,7 @@ If you have found a bug or if you have a feature request, please report them on ### Pull Requests -While we accept Pull Requests on this repository, the SDKs are autogenerated so please consider additionally submitting your Pull Requests to the [sdk-generator](https://github.com/openfga/sdk-generator) and linking the two PRs together and to the corresponding issue. This will greatly assist the OpenFGA team in being able to give timely reviews as well as deploying fixes and updates to our other SDKs as well. +While we accept Pull Requests on this repository, the SDKs are autogenerated so please consider additionally submitting your Pull Requests to the [sdk-generator](https://github.com/openfga/sdk-generator) and linking the two PRs together and to the corresponding issue. This will greatly assist the OpenFGA team in being able to give timely reviews as well as deploying fixes and updates to our other SDKs as well. ## Author diff --git a/docs/ExpandRequest.md b/docs/ExpandRequest.md index c24434b..2c4fa4a 100644 --- a/docs/ExpandRequest.md +++ b/docs/ExpandRequest.md @@ -7,6 +7,7 @@ Name | Type | Description | Notes **tuple_key** | [**ExpandRequestTupleKey**](ExpandRequestTupleKey.md) | | **authorization_model_id** | **str** | | [optional] **consistency** | [**ConsistencyPreference**](ConsistencyPreference.md) | | [optional] +**contextual_tuples** | [**ContextualTupleKeys**](ContextualTupleKeys.md) | | [optional] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/docs/OpenFgaApi.md b/docs/OpenFgaApi.md index ce8e50e..379be95 100644 --- a/docs/OpenFgaApi.md +++ b/docs/OpenFgaApi.md @@ -18,6 +18,7 @@ Method | HTTP request | Description [**read_authorization_model**](OpenFgaApi.md#read_authorization_model) | **GET** /stores/{store_id}/authorization-models/{id} | Return a particular version of an authorization model [**read_authorization_models**](OpenFgaApi.md#read_authorization_models) | **GET** /stores/{store_id}/authorization-models | Return all the authorization models for a particular store [**read_changes**](OpenFgaApi.md#read_changes) | **GET** /stores/{store_id}/changes | Return a list of all the tuple changes +[**streamed_list_objects**](OpenFgaApi.md#streamed_list_objects) | **POST** /stores/{store_id}/streamed-list-objects | Stream all objects of the given type that the user has a relation with [**write**](OpenFgaApi.md#write) | **POST** /stores/{store_id}/write | Add or delete tuples from the store [**write_assertions**](OpenFgaApi.md#write_assertions) | **PUT** /stores/{store_id}/assertions/{authorization_model_id} | Upsert assertions for an authorization model ID [**write_authorization_model**](OpenFgaApi.md#write_authorization_model) | **POST** /stores/{store_id}/authorization-models | Create a new authorization model @@ -359,7 +360,7 @@ No authorization required Expand all relationships in userset tree format, and following userset rewrite rules. Useful to reason about and debug a certain relationship -The Expand API will return all users and usersets that have certain relationship with an object in a certain store. This is different from the `/stores/{store_id}/read` API in that both users and computed usersets are returned. Body parameters `tuple_key.object` and `tuple_key.relation` are all required. The response will return a tree whose leaves are the specific users and usersets. Union, intersection and difference operator are located in the intermediate nodes. ## Example To expand all users that have the `reader` relationship with object `document:2021-budget`, use the Expand API with the following request body ```json { \"tuple_key\": { \"object\": \"document:2021-budget\", \"relation\": \"reader\" }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` OpenFGA's response will be a userset tree of the users and usersets that have read access to the document. ```json { \"tree\":{ \"root\":{ \"type\":\"document:2021-budget#reader\", \"union\":{ \"nodes\":[ { \"type\":\"document:2021-budget#reader\", \"leaf\":{ \"users\":{ \"users\":[ \"user:bob\" ] } } }, { \"type\":\"document:2021-budget#reader\", \"leaf\":{ \"computed\":{ \"userset\":\"document:2021-budget#writer\" } } } ] } } } } ``` The caller can then call expand API for the `writer` relationship for the `document:2021-budget`. +The Expand API will return all users and usersets that have certain relationship with an object in a certain store. This is different from the `/stores/{store_id}/read` API in that both users and computed usersets are returned. Body parameters `tuple_key.object` and `tuple_key.relation` are all required. A `contextual_tuples` object may also be included in the body of the request. This object contains one field `tuple_keys`, which is an array of tuple keys. Each of these tuples may have an associated `condition`. The response will return a tree whose leaves are the specific users and usersets. Union, intersection and difference operator are located in the intermediate nodes. ## Example To expand all users that have the `reader` relationship with object `document:2021-budget`, use the Expand API with the following request body ```json { \"tuple_key\": { \"object\": \"document:2021-budget\", \"relation\": \"reader\" }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` OpenFGA's response will be a userset tree of the users and usersets that have read access to the document. ```json { \"tree\":{ \"root\":{ \"type\":\"document:2021-budget#reader\", \"union\":{ \"nodes\":[ { \"type\":\"document:2021-budget#reader\", \"leaf\":{ \"users\":{ \"users\":[ \"user:bob\" ] } } }, { \"type\":\"document:2021-budget#reader\", \"leaf\":{ \"computed\":{ \"userset\":\"document:2021-budget#writer\" } } } ] } } } } ``` The caller can then call expand API for the `writer` relationship for the `document:2021-budget`. ### Expand Request with Contextual Tuples Given the model ```python model schema 1.1 type user type folder relations define owner: [user] type document relations define parent: [folder] define viewer: [user] or writer define writer: [user] or owner from parent ``` and the initial tuples ```json [{ \"user\": \"user:bob\", \"relation\": \"owner\", \"object\": \"folder:1\" }] ``` To expand all `writers` of `document:1` when `document:1` is put in `folder:1`, the first call could be ```json { \"tuple_key\": { \"object\": \"document:1\", \"relation\": \"writer\" }, \"contextual_tuples\": { \"tuple_keys\": [ { \"user\": \"folder:1\", \"relation\": \"parent\", \"object\": \"document:1\" } ] } } ``` this returns: ```json { \"tree\": { \"root\": { \"name\": \"document:1#writer\", \"union\": { \"nodes\": [ { \"name\": \"document:1#writer\", \"leaf\": { \"users\": { \"users\": [] } } }, { \"name\": \"document:1#writer\", \"leaf\": { \"tupleToUserset\": { \"tupleset\": \"document:1#parent\", \"computed\": [ { \"userset\": \"folder:1#owner\" } ] } } } ] } } } } ``` This tells us that the `owner` of `folder:1` may also be a writer. So our next call could be to find the `owners` of `folder:1` ```json { \"tuple_key\": { \"object\": \"folder:1\", \"relation\": \"owner\" } } ``` which gives ```json { \"tree\": { \"root\": { \"name\": \"folder:1#owner\", \"leaf\": { \"users\": { \"users\": [ \"user:bob\" ] } } } } } ``` ### Example @@ -1199,6 +1200,90 @@ No authorization required [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) +# **streamed_list_objects** +> StreamResultOfStreamedListObjectsResponse streamed_list_objects(body) + +Stream all objects of the given type that the user has a relation with + +The Streamed ListObjects API is very similar to the the ListObjects API, with two differences: 1. Instead of collecting all objects before returning a response, it streams them to the client as they are collected. 2. The number of results returned is only limited by the execution timeout specified in the flag OPENFGA_LIST_OBJECTS_DEADLINE. + +### Example + +```python +import time +import openfga_sdk +from openfga_sdk.rest import ApiException +from pprint import pprint +# To configure the configuration +# host is mandatory +# api_scheme is optional and default to https +# store_id is mandatory +# See configuration.py for a list of all supported configuration parameters. +configuration = openfga_sdk.Configuration( + scheme = "https", + api_host = "api.fga.example", + store_id = 'YOUR_STORE_ID', +) + + +# When authenticating via the API TOKEN method +credentials = Credentials(method='api_token', configuration=CredentialConfiguration(api_token='TOKEN1')) +configuration = openfga_sdk.Configuration( + scheme = "https", + api_host = "api.fga.example", + store_id = 'YOUR_STORE_ID', + credentials = credentials +) + +# Enter a context with an instance of the API client +async with openfga_sdk.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = openfga_sdk.OpenFgaApi(api_client) + body = openfga_sdk.ListObjectsRequest() # ListObjectsRequest | + + try: + # Stream all objects of the given type that the user has a relation with + api_response = await api_instance.api_instance.streamed_list_objects(body) + pprint(api_response) + except ApiException as e: + print("Exception when calling OpenFgaApi->streamed_list_objects: %s\n" % e) + await api_client.close() +``` + + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **body** | [**ListObjectsRequest**](ListObjectsRequest.md)| | + +### Return type + +[**StreamResultOfStreamedListObjectsResponse**](StreamResultOfStreamedListObjectsResponse.md) + +### Authorization + +No authorization required + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: application/json + +### HTTP response details +| Status code | Description | Response headers | +|-------------|-------------|------------------| +**200** | A successful response.(streaming responses) | - | +**400** | Request failed due to invalid input. | - | +**401** | Not authenticated. | - | +**403** | Forbidden. | - | +**404** | Request failed due to incorrect path. | - | +**409** | Request was aborted due a transaction conflict. | - | +**422** | Request timed out due to excessive request throttling. | - | +**500** | Request failed due to internal server error. | - | + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + # **write** > object write(body) diff --git a/docs/StreamResultOfStreamedListObjectsResponse.md b/docs/StreamResultOfStreamedListObjectsResponse.md new file mode 100644 index 0000000..e268a88 --- /dev/null +++ b/docs/StreamResultOfStreamedListObjectsResponse.md @@ -0,0 +1,12 @@ +# StreamResultOfStreamedListObjectsResponse + + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**result** | [**StreamedListObjectsResponse**](StreamedListObjectsResponse.md) | | [optional] +**error** | [**Status**](Status.md) | | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/docs/StreamedListObjectsResponse.md b/docs/StreamedListObjectsResponse.md new file mode 100644 index 0000000..3ae7c63 --- /dev/null +++ b/docs/StreamedListObjectsResponse.md @@ -0,0 +1,12 @@ +# StreamedListObjectsResponse + +The response for a StreamedListObjects RPC. + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**object** | **str** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/example/example1/example1.py b/example/example1/example1.py index 9fc0975..9964835 100644 --- a/example/example1/example1.py +++ b/example/example1/example1.py @@ -1,7 +1,13 @@ import asyncio import os +import sys import uuid +from dotenv import load_dotenv + +sdk_path = os.path.realpath(os.path.join(os.path.abspath(__file__), "..", "..", "..")) +sys.path.insert(0, sdk_path) + from openfga_sdk import ( ClientConfiguration, Condition, @@ -38,6 +44,8 @@ async def main(): + load_dotenv() + credentials = Credentials() if os.getenv("FGA_CLIENT_ID") is not None: credentials = Credentials( diff --git a/example/example1/requirements.txt b/example/example1/requirements.txt index e2a9936..ba25f4e 100644 --- a/example/example1/requirements.txt +++ b/example/example1/requirements.txt @@ -8,3 +8,4 @@ openfga-sdk >= 0.9.0 python-dateutil >= 2.8.2 urllib3 >= 2.1.0 yarl >= 1.9.4 +python-dotenv >= 1, <2 diff --git a/example/streamed-list-objects/.env.example b/example/streamed-list-objects/.env.example new file mode 100644 index 0000000..2bc5757 --- /dev/null +++ b/example/streamed-list-objects/.env.example @@ -0,0 +1 @@ +FGA_API_URL="http://localhost:8080" diff --git a/example/streamed-list-objects/.gitignore b/example/streamed-list-objects/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/example/streamed-list-objects/.gitignore @@ -0,0 +1 @@ +.env diff --git a/example/streamed-list-objects/README.md b/example/streamed-list-objects/README.md new file mode 100644 index 0000000..9750848 --- /dev/null +++ b/example/streamed-list-objects/README.md @@ -0,0 +1,39 @@ +# Streamed List Objects example for OpenFGA's Python SDK + +This example demonstrates working with the `POST` `/stores/:id/streamed-list-objects` endpoint in OpenFGA using the Python SDK. + +## Prerequisites + +If you do not already have an OpenFGA instance running, you can start one using the following command: + +```bash +docker run -d -p 8080:8080 openfga/openfga +``` + +## Configure the example + +You may need to configure the example for your environment: + +```bash +cp .env.example .env +``` + +Now edit the `.env` file and set the values as appropriate. + +## Running the example + +Begin by installing the required dependencies: + +```bash +pip install -r requirements.txt +``` + +Next, run the example. You can use either the synchronous or asynchronous client: + +```bash +python asynchronous.py +``` + +```bash +python synchronous.py +``` diff --git a/example/streamed-list-objects/asynchronous.py b/example/streamed-list-objects/asynchronous.py new file mode 100644 index 0000000..8d34d1f --- /dev/null +++ b/example/streamed-list-objects/asynchronous.py @@ -0,0 +1,121 @@ +import asyncio +import json +import os +import sys +from operator import attrgetter +from typing import Any + +from dotenv import load_dotenv + +sdk_path = os.path.realpath(os.path.join(os.path.abspath(__file__), "..", "..", "..")) +sys.path.insert(0, sdk_path) + +from openfga_sdk import ( + ClientConfiguration, + OpenFgaClient, +) +from openfga_sdk.client.models import ( + ClientListObjectsRequest, + ClientTuple, + ClientWriteRequest, +) +from openfga_sdk.models import CreateStoreRequest + + +class app: + def __init__( + self, + client: OpenFgaClient = None, + configuration: ClientConfiguration = None, + ): + self._client = client + self._configuration = configuration + + async def fga_client(self, env: dict[str, str] = {}) -> OpenFgaClient: + if not self._client or not self._configuration: + load_dotenv() + + if not self._configuration: + self._configuration = ClientConfiguration( + api_url=os.getenv("FGA_API_URL"), + ) + + self._client = OpenFgaClient(self._configuration) + return self._client + + +def unpack( + response, + attr: str, +) -> Any: + return attrgetter(attr)(response) + + +async def main(): + async with await app().fga_client() as fga_client: + # Create a temporary store + store = unpack( + await fga_client.create_store(CreateStoreRequest(name="Test Store")), + "id", + ) + print(f"Created temporary store ({store})") + fga_client.set_store_id(store) + + # Create a temporary authorization model + model = unpack( + await fga_client.write_authorization_model( + json.loads( + '{"schema_version":"1.1","type_definitions":[{"type":"user","relations":{}},{"type":"group","relations":{"member":{"this":{}}},"metadata":{"relations":{"member":{"directly_related_user_types":[{"type":"user"}]}}}},{"type":"folder","relations":{"can_create_file":{"computedUserset":{"object":"","relation":"owner"}},"owner":{"this":{}},"parent":{"this":{}},"viewer":{"union":{"child":[{"this":{}},{"computedUserset":{"object":"","relation":"owner"}},{"tupleToUserset":{"tupleset":{"object":"","relation":"parent"},"computedUserset":{"object":"","relation":"viewer"}}}]}}},"metadata":{"relations":{"can_create_file":{"directly_related_user_types":[]},"owner":{"directly_related_user_types":[{"type":"user"}]},"parent":{"directly_related_user_types":[{"type":"folder"}]},"viewer":{"directly_related_user_types":[{"type":"user"},{"type":"user","wildcard":{}},{"type":"group","relation":"member"}]}}}},{"type":"document","relations":{"can_change_owner":{"computedUserset":{"object":"","relation":"owner"}},"owner":{"this":{}},"parent":{"this":{}},"can_read":{"union":{"child":[{"computedUserset":{"object":"","relation":"viewer"}},{"computedUserset":{"object":"","relation":"owner"}},{"tupleToUserset":{"tupleset":{"object":"","relation":"parent"},"computedUserset":{"object":"","relation":"viewer"}}}]}},"can_share":{"union":{"child":[{"computedUserset":{"object":"","relation":"owner"}},{"tupleToUserset":{"tupleset":{"object":"","relation":"parent"},"computedUserset":{"object":"","relation":"owner"}}}]}},"viewer":{"this":{}},"can_write":{"union":{"child":[{"computedUserset":{"object":"","relation":"owner"}},{"tupleToUserset":{"tupleset":{"object":"","relation":"parent"},"computedUserset":{"object":"","relation":"owner"}}}]}}},"metadata":{"relations":{"can_change_owner":{"directly_related_user_types":[]},"owner":{"directly_related_user_types":[{"type":"user"}]},"parent":{"directly_related_user_types":[{"type":"folder"}]},"can_read":{"directly_related_user_types":[]},"can_share":{"directly_related_user_types":[]},"viewer":{"directly_related_user_types":[{"type":"user"},{"type":"user","wildcard":{}},{"type":"group","relation":"member"}]},"can_write":{"directly_related_user_types":[]}}}}]}' + ) + ), + "authorization_model_id", + ) + print(f"Created temporary authorization model ({model})") + + print(f"Writing 100 mock tuples to store.") + + # Write mock data + writes = [] + for x in range(0, 100): + writes.append( + ClientTuple( + user="user:anne", + relation="owner", + object=f"document:{x}", + ) + ) + + await fga_client.write( + ClientWriteRequest(writes), + { + "authorization_model_id": model, + }, + ) + + print("Listing objects using streaming endpoint:") + results = [] + + request = ClientListObjectsRequest( + type="document", + relation="owner", + user="user:anne", + ) + + async for response in fga_client.streamed_list_objects(request): + print(f" {response}") + results.append(response) + + print(f"API returned {results.__len__()} objects.") + + # Delete the temporary store + try: + await fga_client.delete_store() + print(f"Deleted temporary store ({store})") + except: + pass + + print("Finished.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/example/streamed-list-objects/requirements.txt b/example/streamed-list-objects/requirements.txt new file mode 100644 index 0000000..78af7ab --- /dev/null +++ b/example/streamed-list-objects/requirements.txt @@ -0,0 +1 @@ +python-dotenv >= 1, <2 diff --git a/example/streamed-list-objects/setup.cfg b/example/streamed-list-objects/setup.cfg new file mode 100644 index 0000000..11433ee --- /dev/null +++ b/example/streamed-list-objects/setup.cfg @@ -0,0 +1,2 @@ +[flake8] +max-line-length=99 diff --git a/example/streamed-list-objects/setup.py b/example/streamed-list-objects/setup.py new file mode 100644 index 0000000..a8d1333 --- /dev/null +++ b/example/streamed-list-objects/setup.py @@ -0,0 +1,30 @@ +""" + Python SDK for OpenFGA + + API version: 0.1 + Website: https://openfga.dev + Documentation: https://openfga.dev/docs + Support: https://discord.gg/8naAwJfWN6 + License: [Apache-2.0](https://github.com/openfga/python-sdk/blob/main/LICENSE) + + NOTE: This file was auto generated by OpenAPI Generator (https://openapi-generator.tech). DO NOT EDIT. +""" + +from setuptools import find_packages, setup + +NAME = "openfga-streamed-list-objects-example" +VERSION = "0.0.1" +REQUIRES = [""] + +setup( + name=NAME, + version=VERSION, + description="An example of using the OpenFGA Python SDK with the Streamed List Objects endpoint.", + author="OpenFGA (https://openfga.dev)", + author_email="community@openfga.dev", + url="https://github.com/openfga/python-sdk", + python_requires=">=3.10", + packages=find_packages(exclude=["test", "tests"]), + include_package_data=True, + license="Apache-2.0", +) diff --git a/example/streamed-list-objects/synchronous.py b/example/streamed-list-objects/synchronous.py new file mode 100644 index 0000000..e560a1d --- /dev/null +++ b/example/streamed-list-objects/synchronous.py @@ -0,0 +1,118 @@ +import json +import os +import sys +from operator import attrgetter +from typing import Any + +from dotenv import load_dotenv + +sdk_path = os.path.realpath(os.path.join(os.path.abspath(__file__), "..", "..", "..")) +sys.path.insert(0, sdk_path) + +from openfga_sdk import ClientConfiguration +from openfga_sdk.client.models import ( + ClientListObjectsRequest, + ClientTuple, + ClientWriteRequest, +) +from openfga_sdk.models.create_store_request import CreateStoreRequest +from openfga_sdk.sync import OpenFgaClient + + +class app: + def __init__( + self, + client: OpenFgaClient = None, + configuration: ClientConfiguration = None, + ): + self._client = client + self._configuration = configuration + + def fga_client(self, env: dict[str, str] = {}) -> OpenFgaClient: + if not self._client or not self._configuration: + load_dotenv() + + if not self._configuration: + self._configuration = ClientConfiguration( + api_url=os.getenv("FGA_API_URL"), + ) + + self._client = OpenFgaClient(self._configuration) + return self._client + + +def unpack( + response, + attr: str, +) -> Any: + return attrgetter(attr)(response) + + +def main(): + with app().fga_client() as fga_client: + # Create a temporary store + store = unpack( + fga_client.create_store(CreateStoreRequest(name="Test Store")), + "id", + ) + print(f"Created temporary store ({store})") + fga_client.set_store_id(store) + + # Create a temporary authorization model + model = unpack( + fga_client.write_authorization_model( + json.loads( + '{"schema_version":"1.1","type_definitions":[{"type":"user","relations":{}},{"type":"group","relations":{"member":{"this":{}}},"metadata":{"relations":{"member":{"directly_related_user_types":[{"type":"user"}]}}}},{"type":"folder","relations":{"can_create_file":{"computedUserset":{"object":"","relation":"owner"}},"owner":{"this":{}},"parent":{"this":{}},"viewer":{"union":{"child":[{"this":{}},{"computedUserset":{"object":"","relation":"owner"}},{"tupleToUserset":{"tupleset":{"object":"","relation":"parent"},"computedUserset":{"object":"","relation":"viewer"}}}]}}},"metadata":{"relations":{"can_create_file":{"directly_related_user_types":[]},"owner":{"directly_related_user_types":[{"type":"user"}]},"parent":{"directly_related_user_types":[{"type":"folder"}]},"viewer":{"directly_related_user_types":[{"type":"user"},{"type":"user","wildcard":{}},{"type":"group","relation":"member"}]}}}},{"type":"document","relations":{"can_change_owner":{"computedUserset":{"object":"","relation":"owner"}},"owner":{"this":{}},"parent":{"this":{}},"can_read":{"union":{"child":[{"computedUserset":{"object":"","relation":"viewer"}},{"computedUserset":{"object":"","relation":"owner"}},{"tupleToUserset":{"tupleset":{"object":"","relation":"parent"},"computedUserset":{"object":"","relation":"viewer"}}}]}},"can_share":{"union":{"child":[{"computedUserset":{"object":"","relation":"owner"}},{"tupleToUserset":{"tupleset":{"object":"","relation":"parent"},"computedUserset":{"object":"","relation":"owner"}}}]}},"viewer":{"this":{}},"can_write":{"union":{"child":[{"computedUserset":{"object":"","relation":"owner"}},{"tupleToUserset":{"tupleset":{"object":"","relation":"parent"},"computedUserset":{"object":"","relation":"owner"}}}]}}},"metadata":{"relations":{"can_change_owner":{"directly_related_user_types":[]},"owner":{"directly_related_user_types":[{"type":"user"}]},"parent":{"directly_related_user_types":[{"type":"folder"}]},"can_read":{"directly_related_user_types":[]},"can_share":{"directly_related_user_types":[]},"viewer":{"directly_related_user_types":[{"type":"user"},{"type":"user","wildcard":{}},{"type":"group","relation":"member"}]},"can_write":{"directly_related_user_types":[]}}}}]}' + ) + ), + "authorization_model_id", + ) + print(f"Created temporary authorization model ({model})") + + print(f"Writing 100 mock tuples to store.") + + # Write mock data + writes = [] + for x in range(0, 100): + writes.append( + ClientTuple( + user="user:anne", + relation="owner", + object=f"document:{x}", + ) + ) + + fga_client.write( + ClientWriteRequest(writes), + { + "authorization_model_id": model, + }, + ) + + print("Listing objects using streaming endpoint:") + results = [] + + request = ClientListObjectsRequest( + type="document", + relation="owner", + user="user:anne", + ) + + for response in fga_client.streamed_list_objects(request): + print(f" {response}") + results.append(response) + + print(f"API returned {results.__len__()} objects.") + + # Delete the temporary store + try: + fga_client.delete_store() + print(f"Deleted temporary store ({store})") + except: + pass + + print("Finished.") + + +if __name__ == "__main__": + main() diff --git a/openfga_sdk/__init__.py b/openfga_sdk/__init__.py index 462a10e..0c4b396 100644 --- a/openfga_sdk/__init__.py +++ b/openfga_sdk/__init__.py @@ -91,6 +91,12 @@ from openfga_sdk.models.source_info import SourceInfo from openfga_sdk.models.status import Status from openfga_sdk.models.store import Store +from openfga_sdk.models.stream_result_of_streamed_list_objects_response import ( + StreamResultOfStreamedListObjectsResponse, +) +from openfga_sdk.models.streamed_list_objects_response import ( + StreamedListObjectsResponse, +) from openfga_sdk.models.tuple import Tuple from openfga_sdk.models.tuple_change import TupleChange from openfga_sdk.models.tuple_key import TupleKey diff --git a/openfga_sdk/api/open_fga_api.py b/openfga_sdk/api/open_fga_api.py index ccb7f86..8bbfb94 100644 --- a/openfga_sdk/api/open_fga_api.py +++ b/openfga_sdk/api/open_fga_api.py @@ -122,6 +122,7 @@ async def batch_check_with_http_info(self, body, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -226,6 +227,7 @@ async def batch_check_with_http_info(self, body, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) async def check(self, body, **kwargs): @@ -302,6 +304,7 @@ async def check_with_http_info(self, body, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -405,6 +408,7 @@ async def check_with_http_info(self, body, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) async def create_store(self, body, **kwargs): @@ -481,6 +485,7 @@ async def create_store_with_http_info(self, body, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -571,6 +576,7 @@ async def create_store_with_http_info(self, body, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) async def delete_store(self, **kwargs): @@ -643,6 +649,7 @@ async def delete_store_with_http_info(self, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -718,12 +725,13 @@ async def delete_store_with_http_info(self, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) async def expand(self, body, **kwargs): """Expand all relationships in userset tree format, and following userset rewrite rules. Useful to reason about and debug a certain relationship - The Expand API will return all users and usersets that have certain relationship with an object in a certain store. This is different from the `/stores/{store_id}/read` API in that both users and computed usersets are returned. Body parameters `tuple_key.object` and `tuple_key.relation` are all required. The response will return a tree whose leaves are the specific users and usersets. Union, intersection and difference operator are located in the intermediate nodes. ## Example To expand all users that have the `reader` relationship with object `document:2021-budget`, use the Expand API with the following request body ```json { \"tuple_key\": { \"object\": \"document:2021-budget\", \"relation\": \"reader\" }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` OpenFGA's response will be a userset tree of the users and usersets that have read access to the document. ```json { \"tree\":{ \"root\":{ \"type\":\"document:2021-budget#reader\", \"union\":{ \"nodes\":[ { \"type\":\"document:2021-budget#reader\", \"leaf\":{ \"users\":{ \"users\":[ \"user:bob\" ] } } }, { \"type\":\"document:2021-budget#reader\", \"leaf\":{ \"computed\":{ \"userset\":\"document:2021-budget#writer\" } } } ] } } } } ``` The caller can then call expand API for the `writer` relationship for the `document:2021-budget`. + The Expand API will return all users and usersets that have certain relationship with an object in a certain store. This is different from the `/stores/{store_id}/read` API in that both users and computed usersets are returned. Body parameters `tuple_key.object` and `tuple_key.relation` are all required. A `contextual_tuples` object may also be included in the body of the request. This object contains one field `tuple_keys`, which is an array of tuple keys. Each of these tuples may have an associated `condition`. The response will return a tree whose leaves are the specific users and usersets. Union, intersection and difference operator are located in the intermediate nodes. ## Example To expand all users that have the `reader` relationship with object `document:2021-budget`, use the Expand API with the following request body ```json { \"tuple_key\": { \"object\": \"document:2021-budget\", \"relation\": \"reader\" }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` OpenFGA's response will be a userset tree of the users and usersets that have read access to the document. ```json { \"tree\":{ \"root\":{ \"type\":\"document:2021-budget#reader\", \"union\":{ \"nodes\":[ { \"type\":\"document:2021-budget#reader\", \"leaf\":{ \"users\":{ \"users\":[ \"user:bob\" ] } } }, { \"type\":\"document:2021-budget#reader\", \"leaf\":{ \"computed\":{ \"userset\":\"document:2021-budget#writer\" } } } ] } } } } ``` The caller can then call expand API for the `writer` relationship for the `document:2021-budget`. ### Expand Request with Contextual Tuples Given the model ```python model schema 1.1 type user type folder relations define owner: [user] type document relations define parent: [folder] define viewer: [user] or writer define writer: [user] or owner from parent ``` and the initial tuples ```json [{ \"user\": \"user:bob\", \"relation\": \"owner\", \"object\": \"folder:1\" }] ``` To expand all `writers` of `document:1` when `document:1` is put in `folder:1`, the first call could be ```json { \"tuple_key\": { \"object\": \"document:1\", \"relation\": \"writer\" }, \"contextual_tuples\": { \"tuple_keys\": [ { \"user\": \"folder:1\", \"relation\": \"parent\", \"object\": \"document:1\" } ] } } ``` this returns: ```json { \"tree\": { \"root\": { \"name\": \"document:1#writer\", \"union\": { \"nodes\": [ { \"name\": \"document:1#writer\", \"leaf\": { \"users\": { \"users\": [] } } }, { \"name\": \"document:1#writer\", \"leaf\": { \"tupleToUserset\": { \"tupleset\": \"document:1#parent\", \"computed\": [ { \"userset\": \"folder:1#owner\" } ] } } } ] } } } } ``` This tells us that the `owner` of `folder:1` may also be a writer. So our next call could be to find the `owners` of `folder:1` ```json { \"tuple_key\": { \"object\": \"folder:1\", \"relation\": \"owner\" } } ``` which gives ```json { \"tree\": { \"root\": { \"name\": \"folder:1#owner\", \"leaf\": { \"users\": { \"users\": [ \"user:bob\" ] } } } } } ``` >>> thread = await api.expand(body) @@ -750,7 +758,7 @@ async def expand(self, body, **kwargs): async def expand_with_http_info(self, body, **kwargs): """Expand all relationships in userset tree format, and following userset rewrite rules. Useful to reason about and debug a certain relationship - The Expand API will return all users and usersets that have certain relationship with an object in a certain store. This is different from the `/stores/{store_id}/read` API in that both users and computed usersets are returned. Body parameters `tuple_key.object` and `tuple_key.relation` are all required. The response will return a tree whose leaves are the specific users and usersets. Union, intersection and difference operator are located in the intermediate nodes. ## Example To expand all users that have the `reader` relationship with object `document:2021-budget`, use the Expand API with the following request body ```json { \"tuple_key\": { \"object\": \"document:2021-budget\", \"relation\": \"reader\" }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` OpenFGA's response will be a userset tree of the users and usersets that have read access to the document. ```json { \"tree\":{ \"root\":{ \"type\":\"document:2021-budget#reader\", \"union\":{ \"nodes\":[ { \"type\":\"document:2021-budget#reader\", \"leaf\":{ \"users\":{ \"users\":[ \"user:bob\" ] } } }, { \"type\":\"document:2021-budget#reader\", \"leaf\":{ \"computed\":{ \"userset\":\"document:2021-budget#writer\" } } } ] } } } } ``` The caller can then call expand API for the `writer` relationship for the `document:2021-budget`. + The Expand API will return all users and usersets that have certain relationship with an object in a certain store. This is different from the `/stores/{store_id}/read` API in that both users and computed usersets are returned. Body parameters `tuple_key.object` and `tuple_key.relation` are all required. A `contextual_tuples` object may also be included in the body of the request. This object contains one field `tuple_keys`, which is an array of tuple keys. Each of these tuples may have an associated `condition`. The response will return a tree whose leaves are the specific users and usersets. Union, intersection and difference operator are located in the intermediate nodes. ## Example To expand all users that have the `reader` relationship with object `document:2021-budget`, use the Expand API with the following request body ```json { \"tuple_key\": { \"object\": \"document:2021-budget\", \"relation\": \"reader\" }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` OpenFGA's response will be a userset tree of the users and usersets that have read access to the document. ```json { \"tree\":{ \"root\":{ \"type\":\"document:2021-budget#reader\", \"union\":{ \"nodes\":[ { \"type\":\"document:2021-budget#reader\", \"leaf\":{ \"users\":{ \"users\":[ \"user:bob\" ] } } }, { \"type\":\"document:2021-budget#reader\", \"leaf\":{ \"computed\":{ \"userset\":\"document:2021-budget#writer\" } } } ] } } } } ``` The caller can then call expand API for the `writer` relationship for the `document:2021-budget`. ### Expand Request with Contextual Tuples Given the model ```python model schema 1.1 type user type folder relations define owner: [user] type document relations define parent: [folder] define viewer: [user] or writer define writer: [user] or owner from parent ``` and the initial tuples ```json [{ \"user\": \"user:bob\", \"relation\": \"owner\", \"object\": \"folder:1\" }] ``` To expand all `writers` of `document:1` when `document:1` is put in `folder:1`, the first call could be ```json { \"tuple_key\": { \"object\": \"document:1\", \"relation\": \"writer\" }, \"contextual_tuples\": { \"tuple_keys\": [ { \"user\": \"folder:1\", \"relation\": \"parent\", \"object\": \"document:1\" } ] } } ``` this returns: ```json { \"tree\": { \"root\": { \"name\": \"document:1#writer\", \"union\": { \"nodes\": [ { \"name\": \"document:1#writer\", \"leaf\": { \"users\": { \"users\": [] } } }, { \"name\": \"document:1#writer\", \"leaf\": { \"tupleToUserset\": { \"tupleset\": \"document:1#parent\", \"computed\": [ { \"userset\": \"folder:1#owner\" } ] } } } ] } } } } ``` This tells us that the `owner` of `folder:1` may also be a writer. So our next call could be to find the `owners` of `folder:1` ```json { \"tuple_key\": { \"object\": \"folder:1\", \"relation\": \"owner\" } } ``` which gives ```json { \"tree\": { \"root\": { \"name\": \"folder:1#owner\", \"leaf\": { \"users\": { \"users\": [ \"user:bob\" ] } } } } } ``` >>> thread = api.expand_with_http_info(body) @@ -794,6 +802,7 @@ async def expand_with_http_info(self, body, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -897,6 +906,7 @@ async def expand_with_http_info(self, body, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) async def get_store(self, **kwargs): @@ -969,6 +979,7 @@ async def get_store_with_http_info(self, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -1053,6 +1064,7 @@ async def get_store_with_http_info(self, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) async def list_objects(self, body, **kwargs): @@ -1129,6 +1141,7 @@ async def list_objects_with_http_info(self, body, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -1233,6 +1246,7 @@ async def list_objects_with_http_info(self, body, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) async def list_stores(self, **kwargs): @@ -1313,6 +1327,7 @@ async def list_stores_with_http_info(self, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -1397,6 +1412,7 @@ async def list_stores_with_http_info(self, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) async def list_users(self, body, **kwargs): @@ -1473,6 +1489,7 @@ async def list_users_with_http_info(self, body, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -1577,6 +1594,7 @@ async def list_users_with_http_info(self, body, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) async def read(self, body, **kwargs): @@ -1653,6 +1671,7 @@ async def read_with_http_info(self, body, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -1756,6 +1775,7 @@ async def read_with_http_info(self, body, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) async def read_assertions(self, authorization_model_id, **kwargs): @@ -1834,6 +1854,7 @@ async def read_assertions_with_http_info(self, authorization_model_id, **kwargs) "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -1933,6 +1954,7 @@ async def read_assertions_with_http_info(self, authorization_model_id, **kwargs) _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) async def read_authorization_model(self, id, **kwargs): @@ -2009,6 +2031,7 @@ async def read_authorization_model_with_http_info(self, id, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -2106,6 +2129,7 @@ async def read_authorization_model_with_http_info(self, id, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) async def read_authorization_models(self, **kwargs): @@ -2186,6 +2210,7 @@ async def read_authorization_models_with_http_info(self, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -2276,6 +2301,7 @@ async def read_authorization_models_with_http_info(self, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) async def read_changes(self, **kwargs): @@ -2364,6 +2390,7 @@ async def read_changes_with_http_info(self, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -2458,6 +2485,189 @@ async def read_changes_with_http_info(self, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), + ) + + async def streamed_list_objects(self, body, **kwargs): + """Stream all objects of the given type that the user has a relation with + + The Streamed ListObjects API is very similar to the the ListObjects API, with two differences: 1. Instead of collecting all objects before returning a response, it streams them to the client as they are collected. 2. The number of results returned is only limited by the execution timeout specified in the flag OPENFGA_LIST_OBJECTS_DEADLINE. + + >>> thread = await api.streamed_list_objects(body) + + :param body: (required) + :type body: ListObjectsRequest + :param async_req: Whether to execute the request asynchronously. + :type async_req: bool, optional + :param _preload_content: if False, the urllib3.HTTPResponse object will + be returned without reading/decoding response + data. Default is True. + :type _preload_content: bool, optional + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :return: Returns the result object. + If the method is called asynchronously, + returns the request thread. + :rtype: StreamResultOfStreamedListObjectsResponse + """ + kwargs["_return_http_data_only"] = True + return await self.streamed_list_objects_with_http_info(body, **kwargs) + + async def streamed_list_objects_with_http_info(self, body, **kwargs): + """Stream all objects of the given type that the user has a relation with + + The Streamed ListObjects API is very similar to the the ListObjects API, with two differences: 1. Instead of collecting all objects before returning a response, it streams them to the client as they are collected. 2. The number of results returned is only limited by the execution timeout specified in the flag OPENFGA_LIST_OBJECTS_DEADLINE. + + >>> thread = api.streamed_list_objects_with_http_info(body) + + :param body: (required) + :type body: ListObjectsRequest + :param async_req: Whether to execute the request asynchronously. + :type async_req: bool, optional + :param _return_http_data_only: response data without head status code + and headers + :type _return_http_data_only: bool, optional + :param _preload_content: if False, the urllib3.HTTPResponse object will + be returned without reading/decoding response + data. Default is True. + :type _preload_content: bool, optional + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the authentication + in the spec for a single request. + :param _retry_param: if specified, override the retry parameters specified in configuration + :type _request_auth: dict, optional + :type _content_type: string, optional: force content-type for the request + :return: Returns the result object. + If the method is called asynchronously, + returns the request thread. + :rtype: tuple(StreamResultOfStreamedListObjectsResponse, status_code(int), headers(HTTPHeaderDict)) + """ + + local_var_params = locals() + + all_params = ["body"] + all_params.extend( + [ + "async_req", + "_return_http_data_only", + "_preload_content", + "_request_timeout", + "_request_auth", + "_content_type", + "_headers", + "_retry_params", + "_streaming", + ] + ) + + for key, val in local_var_params["kwargs"].items(): + if key not in all_params: + raise FgaValidationException( + "Got an unexpected keyword argument '%s'" + " to method streamed_list_objects" % key + ) + local_var_params[key] = val + del local_var_params["kwargs"] + # verify the required parameter 'body' is set + if ( + self.api_client.client_side_validation + and local_var_params.get("body") is None + ): + raise ApiValueError( + "Missing the required parameter `body` when calling `streamed_list_objects`" + ) + + collection_formats = {} + + path_params = {} + + store_id = None + + if self.api_client._get_store_id() is None: + raise ApiValueError( + "Store ID expected in api_client's configuration when calling `streamed_list_objects`" + ) + store_id = self.api_client._get_store_id() + + query_params = [] + + header_params = dict(local_var_params.get("_headers", {})) + + form_params = [] + local_var_files = {} + + body_params = None + if "body" in local_var_params: + body_params = local_var_params["body"] + # HTTP header `Accept` + header_params["Accept"] = self.api_client.select_header_accept( + ["application/json"] + ) + + # HTTP header `Content-Type` + content_types_list = local_var_params.get( + "_content_type", + self.api_client.select_header_content_type( + ["application/json"], "POST", body_params + ), + ) + if content_types_list: + header_params["Content-Type"] = content_types_list + + # Authentication setting + auth_settings = [] + + response_types_map = { + 200: "StreamResultOfStreamedListObjectsResponse", + 400: "ValidationErrorMessageResponse", + 401: "UnauthenticatedResponse", + 403: "ForbiddenResponse", + 404: "PathUnknownErrorMessageResponse", + 409: "AbortedMessageResponse", + 422: "UnprocessableContentMessageResponse", + 500: "InternalErrorMessageResponse", + } + + telemetry_attributes: dict[TelemetryAttribute, str | int] = { + TelemetryAttributes.fga_client_request_method: "streamed_list_objects", + TelemetryAttributes.fga_client_request_store_id: self.api_client.get_store_id(), + TelemetryAttributes.fga_client_request_model_id: local_var_params.get( + "authorization_model_id", "" + ), + } + + telemetry_attributes = TelemetryAttributes.fromBody( + body=body_params, + attributes=telemetry_attributes, + ) + + return await self.api_client.call_api( + "/stores/{store_id}/streamed-list-objects".replace("{store_id}", store_id), + "POST", + path_params, + query_params, + header_params, + body=body_params, + post_params=form_params, + files=local_var_files, + response_types_map=response_types_map, + auth_settings=auth_settings, + async_req=local_var_params.get("async_req"), + _return_http_data_only=local_var_params.get("_return_http_data_only"), + _preload_content=local_var_params.get("_preload_content", True), + _request_timeout=local_var_params.get("_request_timeout"), + _retry_params=local_var_params.get("_retry_params"), + collection_formats=collection_formats, + _request_auth=local_var_params.get("_request_auth"), + _oauth2_client=self._oauth2_client, + _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) async def write(self, body, **kwargs): @@ -2534,6 +2744,7 @@ async def write_with_http_info(self, body, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -2637,6 +2848,7 @@ async def write_with_http_info(self, body, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) async def write_assertions(self, authorization_model_id, body, **kwargs): @@ -2721,6 +2933,7 @@ async def write_assertions_with_http_info( "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -2831,6 +3044,7 @@ async def write_assertions_with_http_info( _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) async def write_authorization_model(self, body, **kwargs): @@ -2907,6 +3121,7 @@ async def write_authorization_model_with_http_info(self, body, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -3011,4 +3226,5 @@ async def write_authorization_model_with_http_info(self, body, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) diff --git a/openfga_sdk/api_client.py b/openfga_sdk/api_client.py index 8ab0e71..841d87e 100644 --- a/openfga_sdk/api_client.py +++ b/openfga_sdk/api_client.py @@ -167,6 +167,7 @@ async def __call_api( _retry_params=None, _oauth2_client=None, _telemetry_attributes: dict[TelemetryAttribute, str | int] = None, + _streaming: bool = False, ): self.configuration.is_valid() @@ -281,6 +282,7 @@ async def __call_api( body=body, _preload_content=_preload_content, _request_timeout=_request_timeout, + _streaming=_streaming, ) except (RateLimitExceededError, ServiceException) as e: if retry < max_retry and e.status != 501: @@ -362,7 +364,7 @@ async def __call_api( configuration=self.configuration.telemetry, ) - if not _preload_content: + if not _preload_content or _streaming: return return_data response_type = response_types_map.get(response_data.status, None) @@ -508,6 +510,7 @@ async def call_api( _retry_params=None, _oauth2_client=None, _telemetry_attributes: dict[TelemetryAttribute, str | int] = None, + _streaming: bool = False, ): """Makes the HTTP request (synchronous) and returns deserialized data. @@ -569,6 +572,7 @@ async def call_api( _retry_params, _oauth2_client, _telemetry_attributes, + _streaming, ) return self.pool.apply_async( @@ -592,6 +596,7 @@ async def call_api( _retry_params, _oauth2_client, _telemetry_attributes, + _streaming, ), ) @@ -605,77 +610,36 @@ async def request( body=None, _preload_content=True, _request_timeout=None, + _streaming: bool = False, ): - """Makes the HTTP request using RESTClient.""" - if method == "GET": - return await self.rest_client.GET( - url, - query_params=query_params, - _preload_content=_preload_content, - _request_timeout=_request_timeout, - headers=headers, - ) - elif method == "HEAD": - return await self.rest_client.HEAD( - url, - query_params=query_params, - _preload_content=_preload_content, - _request_timeout=_request_timeout, - headers=headers, - ) - elif method == "OPTIONS": - return await self.rest_client.OPTIONS( - url, - query_params=query_params, - headers=headers, - _preload_content=_preload_content, - _request_timeout=_request_timeout, - ) - elif method == "POST": - return await self.rest_client.POST( - url, - query_params=query_params, - headers=headers, - post_params=post_params, - _preload_content=_preload_content, - _request_timeout=_request_timeout, - body=body, - ) - elif method == "PUT": - return await self.rest_client.PUT( - url, - query_params=query_params, - headers=headers, - post_params=post_params, - _preload_content=_preload_content, - _request_timeout=_request_timeout, - body=body, + if method not in ["GET", "HEAD", "OPTIONS", "POST", "PATCH", "PUT", "DELETE"]: + raise ApiValueError( + "http method must be `GET`, `HEAD`, `OPTIONS`," + " `POST`, `PATCH`, `PUT` or `DELETE`." ) - elif method == "PATCH": - return await self.rest_client.PATCH( + + if _streaming: + return self.rest_client.stream( + method, url, query_params=query_params, headers=headers, post_params=post_params, - _preload_content=_preload_content, - _request_timeout=_request_timeout, body=body, - ) - elif method == "DELETE": - return await self.rest_client.DELETE( - url, - query_params=query_params, - headers=headers, - _preload_content=_preload_content, _request_timeout=_request_timeout, - body=body, - ) - else: - raise ApiValueError( - "http method must be `GET`, `HEAD`, `OPTIONS`," - " `POST`, `PATCH`, `PUT` or `DELETE`." ) + return await self.rest_client.request( + method, + url, + query_params=query_params, + headers=headers, + post_params=post_params, + body=body, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + ) + def parameters_to_tuples(self, params, collection_formats): """Get parameters as list of tuples, formatting collections. diff --git a/openfga_sdk/client/client.py b/openfga_sdk/client/client.py index bb795cd..b9b0387 100644 --- a/openfga_sdk/client/client.py +++ b/openfga_sdk/client/client.py @@ -66,6 +66,9 @@ ) from openfga_sdk.models.read_request import ReadRequest from openfga_sdk.models.read_request_tuple_key import ReadRequestTupleKey +from openfga_sdk.models.streamed_list_objects_response import ( + StreamedListObjectsResponse, +) from openfga_sdk.models.tuple_key import TupleKey from openfga_sdk.models.write_assertions_request import WriteAssertionsRequest from openfga_sdk.models.write_authorization_model_request import ( @@ -818,6 +821,43 @@ async def list_objects( api_response = await self._api.list_objects(body=req_body, **kwargs) return api_response + async def streamed_list_objects( + self, body: ClientListObjectsRequest, options: dict[str, str] = None + ): + """ + Retrieve all objects of the given type that the user has a relation with, using the streaming ListObjects API. + + :param body - list object parameters + :param authorization_model_id(options) - Overrides the authorization model id in the configuration + :param header(options) - Custom headers to send alongside the request + :param retryParams(options) - Override the retry parameters for this request + :param retryParams.maxRetry(options) - Override the max number of retries on each API request + :param retryParams.minWaitInMs(options) - Override the minimum wait before a retry is initiated + :param consistency(options) - The type of consistency preferred for the request11 + """ + kwargs = options_to_kwargs(options) + kwargs["_streaming"] = True + + req_body = ListObjectsRequest( + authorization_model_id=self._get_authorization_model_id(options), + user=body.user, + relation=body.relation, + type=body.type, + context=body.context, + consistency=self._get_consistency(options), + ) + + if body.contextual_tuples: + req_body.contextual_tuples = ContextualTupleKeys( + tuple_keys=convert_tuple_keys(body.contextual_tuples) + ) + + async for response in await self._api.streamed_list_objects( + body=req_body, **kwargs + ): + if response and "result" in response and "object" in response["result"]: + yield StreamedListObjectsResponse(response["result"]["object"]) + async def list_relations( self, body: ClientListRelationsRequest, options: dict[str, str] = None ): diff --git a/openfga_sdk/models/__init__.py b/openfga_sdk/models/__init__.py index 523c9b0..50c8283 100644 --- a/openfga_sdk/models/__init__.py +++ b/openfga_sdk/models/__init__.py @@ -76,6 +76,12 @@ from openfga_sdk.models.source_info import SourceInfo from openfga_sdk.models.status import Status from openfga_sdk.models.store import Store +from openfga_sdk.models.stream_result_of_streamed_list_objects_response import ( + StreamResultOfStreamedListObjectsResponse, +) +from openfga_sdk.models.streamed_list_objects_response import ( + StreamedListObjectsResponse, +) from openfga_sdk.models.tuple import Tuple from openfga_sdk.models.tuple_change import TupleChange from openfga_sdk.models.tuple_key import TupleKey diff --git a/openfga_sdk/models/expand_request.py b/openfga_sdk/models/expand_request.py index 08c7e27..ff33b1b 100644 --- a/openfga_sdk/models/expand_request.py +++ b/openfga_sdk/models/expand_request.py @@ -37,12 +37,14 @@ class ExpandRequest: "tuple_key": "ExpandRequestTupleKey", "authorization_model_id": "str", "consistency": "ConsistencyPreference", + "contextual_tuples": "ContextualTupleKeys", } attribute_map = { "tuple_key": "tuple_key", "authorization_model_id": "authorization_model_id", "consistency": "consistency", + "contextual_tuples": "contextual_tuples", } def __init__( @@ -50,6 +52,7 @@ def __init__( tuple_key=None, authorization_model_id=None, consistency=None, + contextual_tuples=None, local_vars_configuration=None, ): """ExpandRequest - a model defined in OpenAPI""" @@ -60,6 +63,7 @@ def __init__( self._tuple_key = None self._authorization_model_id = None self._consistency = None + self._contextual_tuples = None self.discriminator = None self.tuple_key = tuple_key @@ -67,6 +71,8 @@ def __init__( self.authorization_model_id = authorization_model_id if consistency is not None: self.consistency = consistency + if contextual_tuples is not None: + self.contextual_tuples = contextual_tuples @property def tuple_key(self): @@ -133,6 +139,27 @@ def consistency(self, consistency): self._consistency = consistency + @property + def contextual_tuples(self): + """Gets the contextual_tuples of this ExpandRequest. + + + :return: The contextual_tuples of this ExpandRequest. + :rtype: ContextualTupleKeys + """ + return self._contextual_tuples + + @contextual_tuples.setter + def contextual_tuples(self, contextual_tuples): + """Sets the contextual_tuples of this ExpandRequest. + + + :param contextual_tuples: The contextual_tuples of this ExpandRequest. + :type contextual_tuples: ContextualTupleKeys + """ + + self._contextual_tuples = contextual_tuples + def to_dict(self, serialize=False): """Returns the model properties as a dict""" result = {} diff --git a/openfga_sdk/models/stream_result_of_streamed_list_objects_response.py b/openfga_sdk/models/stream_result_of_streamed_list_objects_response.py new file mode 100644 index 0000000..ecb4a7f --- /dev/null +++ b/openfga_sdk/models/stream_result_of_streamed_list_objects_response.py @@ -0,0 +1,145 @@ +""" + Python SDK for OpenFGA + + API version: 1.x + Website: https://openfga.dev + Documentation: https://openfga.dev/docs + Support: https://openfga.dev/community + License: [Apache-2.0](https://github.com/openfga/python-sdk/blob/main/LICENSE) + + NOTE: This file was auto generated by OpenAPI Generator (https://openapi-generator.tech). DO NOT EDIT. +""" + +try: + from inspect import getfullargspec +except ImportError: + from inspect import getargspec as getfullargspec +import pprint + +from openfga_sdk.configuration import Configuration + + +class StreamResultOfStreamedListObjectsResponse: + """NOTE: This class is auto generated by OpenAPI Generator. + Ref: https://openapi-generator.tech + + Do not edit the class manually. + """ + + """ + Attributes: + openapi_types (dict): The key is attribute name + and the value is attribute type. + attribute_map (dict): The key is attribute name + and the value is json key in definition. + """ + openapi_types = {"result": "StreamedListObjectsResponse", "error": "Status"} + + attribute_map = {"result": "result", "error": "error"} + + def __init__(self, result=None, error=None, local_vars_configuration=None): + """StreamResultOfStreamedListObjectsResponse - a model defined in OpenAPI""" + if local_vars_configuration is None: + local_vars_configuration = Configuration.get_default_copy() + self.local_vars_configuration = local_vars_configuration + + self._result = None + self._error = None + self.discriminator = None + + if result is not None: + self.result = result + if error is not None: + self.error = error + + @property + def result(self): + """Gets the result of this StreamResultOfStreamedListObjectsResponse. + + + :return: The result of this StreamResultOfStreamedListObjectsResponse. + :rtype: StreamedListObjectsResponse + """ + return self._result + + @result.setter + def result(self, result): + """Sets the result of this StreamResultOfStreamedListObjectsResponse. + + + :param result: The result of this StreamResultOfStreamedListObjectsResponse. + :type result: StreamedListObjectsResponse + """ + + self._result = result + + @property + def error(self): + """Gets the error of this StreamResultOfStreamedListObjectsResponse. + + + :return: The error of this StreamResultOfStreamedListObjectsResponse. + :rtype: Status + """ + return self._error + + @error.setter + def error(self, error): + """Sets the error of this StreamResultOfStreamedListObjectsResponse. + + + :param error: The error of this StreamResultOfStreamedListObjectsResponse. + :type error: Status + """ + + self._error = error + + def to_dict(self, serialize=False): + """Returns the model properties as a dict""" + result = {} + + def convert(x): + if hasattr(x, "to_dict"): + args = getfullargspec(x.to_dict).args + if len(args) == 1: + return x.to_dict() + else: + return x.to_dict(serialize) + else: + return x + + for attr, _ in self.openapi_types.items(): + value = getattr(self, attr) + attr = self.attribute_map.get(attr, attr) if serialize else attr + if isinstance(value, list): + result[attr] = list(map(lambda x: convert(x), value)) + elif isinstance(value, dict): + result[attr] = dict( + map(lambda item: (item[0], convert(item[1])), value.items()) + ) + else: + result[attr] = convert(value) + + return result + + def to_str(self): + """Returns the string representation of the model""" + return pprint.pformat(self.to_dict()) + + def __repr__(self): + """For `print` and `pprint`""" + return self.to_str() + + def __eq__(self, other): + """Returns true if both objects are equal""" + if not isinstance(other, StreamResultOfStreamedListObjectsResponse): + return False + + return self.to_dict() == other.to_dict() + + def __ne__(self, other): + """Returns true if both objects are not equal""" + if not isinstance(other, StreamResultOfStreamedListObjectsResponse): + return True + + return self.to_dict() != other.to_dict() diff --git a/openfga_sdk/models/streamed_list_objects_response.py b/openfga_sdk/models/streamed_list_objects_response.py new file mode 100644 index 0000000..042b936 --- /dev/null +++ b/openfga_sdk/models/streamed_list_objects_response.py @@ -0,0 +1,122 @@ +""" + Python SDK for OpenFGA + + API version: 1.x + Website: https://openfga.dev + Documentation: https://openfga.dev/docs + Support: https://openfga.dev/community + License: [Apache-2.0](https://github.com/openfga/python-sdk/blob/main/LICENSE) + + NOTE: This file was auto generated by OpenAPI Generator (https://openapi-generator.tech). DO NOT EDIT. +""" + +try: + from inspect import getfullargspec +except ImportError: + from inspect import getargspec as getfullargspec +import pprint + +from openfga_sdk.configuration import Configuration + + +class StreamedListObjectsResponse: + """NOTE: This class is auto generated by OpenAPI Generator. + Ref: https://openapi-generator.tech + + Do not edit the class manually. + """ + + """ + Attributes: + openapi_types (dict): The key is attribute name + and the value is attribute type. + attribute_map (dict): The key is attribute name + and the value is json key in definition. + """ + openapi_types = {"object": "str"} + + attribute_map = {"object": "object"} + + def __init__(self, object=None, local_vars_configuration=None): + """StreamedListObjectsResponse - a model defined in OpenAPI""" + if local_vars_configuration is None: + local_vars_configuration = Configuration.get_default_copy() + self.local_vars_configuration = local_vars_configuration + + self._object = None + self.discriminator = None + + self.object = object + + @property + def object(self): + """Gets the object of this StreamedListObjectsResponse. + + + :return: The object of this StreamedListObjectsResponse. + :rtype: str + """ + return self._object + + @object.setter + def object(self, object): + """Sets the object of this StreamedListObjectsResponse. + + + :param object: The object of this StreamedListObjectsResponse. + :type object: str + """ + if self.local_vars_configuration.client_side_validation and object is None: + raise ValueError("Invalid value for `object`, must not be `None`") + + self._object = object + + def to_dict(self, serialize=False): + """Returns the model properties as a dict""" + result = {} + + def convert(x): + if hasattr(x, "to_dict"): + args = getfullargspec(x.to_dict).args + if len(args) == 1: + return x.to_dict() + else: + return x.to_dict(serialize) + else: + return x + + for attr, _ in self.openapi_types.items(): + value = getattr(self, attr) + attr = self.attribute_map.get(attr, attr) if serialize else attr + if isinstance(value, list): + result[attr] = list(map(lambda x: convert(x), value)) + elif isinstance(value, dict): + result[attr] = dict( + map(lambda item: (item[0], convert(item[1])), value.items()) + ) + else: + result[attr] = convert(value) + + return result + + def to_str(self): + """Returns the string representation of the model""" + return pprint.pformat(self.to_dict()) + + def __repr__(self): + """For `print` and `pprint`""" + return self.to_str() + + def __eq__(self, other): + """Returns true if both objects are equal""" + if not isinstance(other, StreamedListObjectsResponse): + return False + + return self.to_dict() == other.to_dict() + + def __ne__(self, other): + """Returns true if both objects are not equal""" + if not isinstance(other, StreamedListObjectsResponse): + return True + + return self.to_dict() != other.to_dict() diff --git a/openfga_sdk/oauth2.py b/openfga_sdk/oauth2.py index 6b45882..5f46c76 100644 --- a/openfga_sdk/oauth2.py +++ b/openfga_sdk/oauth2.py @@ -106,8 +106,15 @@ async def _obtain_token(self, client): ) for attempt in range(max_retry + 1): - raw_response = await client.POST( - token_url, headers=headers, post_params=post_params + raw_response = await client.request( + method="POST", + url=token_url, + headers=headers, + query_params=None, + body=None, + _preload_content=True, + _request_timeout=None, + post_params=post_params, ) if 500 <= raw_response.status <= 599 or raw_response.status == 429: diff --git a/openfga_sdk/rest.py b/openfga_sdk/rest.py index 726c438..9fe2365 100644 --- a/openfga_sdk/rest.py +++ b/openfga_sdk/rest.py @@ -16,6 +16,7 @@ import re import ssl import urllib +from typing import Any, List, Optional, Tuple import aiohttp @@ -34,27 +35,54 @@ class RESTResponse(io.IOBase): + """ + Represents an HTTP response object. + """ - def __init__(self, resp, data): + def __init__(self, resp: aiohttp.ClientResponse, data: bytes) -> None: + """ + Initializes a RESTResponse with an aiohttp response and corresponding data. + + :param resp: The aiohttp.ClientResponse object. + :param data: The raw byte data read from the response. + """ self.aiohttp_response = resp self.status = resp.status self.reason = resp.reason self.data = data - def getheaders(self): - """Returns a CIMultiDictProxy of the response headers.""" + def getheaders(self) -> aiohttp.typedefs.LooseHeaders: + """ + Returns the response headers. + """ return self.aiohttp_response.headers - def getheader(self, name, default=None): - """Returns a given response header.""" + def getheader(self, name: str, default: Optional[str] = None) -> Optional[str]: + """ + Returns a specific header value by name. + + :param name: The name of the header. + :param default: The default value if header is not found. + :return: The header value, or default if not present. + """ return self.aiohttp_response.headers.get(name, default) class RESTClientObject: + """ + A client object that manages HTTP interactions. + """ - def __init__(self, configuration, pools_size=4, maxsize=None): + def __init__( + self, configuration: Any, pools_size: int = 4, maxsize: Optional[int] = None + ) -> None: + """ + Creates a new RESTClientObject. - # maxsize is number of requests to host that are allowed in parallel + :param configuration: A configuration object with necessary parameters. + :param pools_size: The size of the connection pool (unused, present for compatibility). + :param maxsize: Maximum number of connections to allow. + """ if maxsize is None: maxsize = configuration.connection_pool_maxsize @@ -69,44 +97,40 @@ def __init__(self, configuration, pools_size=4, maxsize=None): ssl_context.verify_mode = ssl.CERT_NONE connector = aiohttp.TCPConnector(limit=maxsize, ssl=ssl_context) - self.proxy = configuration.proxy self.proxy_headers = configuration.proxy_headers self._timeout_millisec = configuration.timeout_millisec - - # https pool manager self.pool_manager = aiohttp.ClientSession(connector=connector, trust_env=True) - async def close(self): + async def close(self) -> None: + """ + Closes the underlying aiohttp.ClientSession. + """ await self.pool_manager.close() - async def request( + async def build_request( self, - method, - url, - query_params=None, - headers=None, - body=None, - post_params=None, - _preload_content=True, - _request_timeout=None, - ): - """Execute request - - :param method: http request method - :param url: http request url - :param query_params: query parameters in the url - :param headers: http request headers - :param body: request json body, for `application/json` - :param post_params: request post parameters, - `application/x-www-form-urlencoded` - and `multipart/form-data` - :param _preload_content: this is a non-applicable field for - the AiohttpClient. - :param _request_timeout: timeout setting for this request. If one - number provided, it will be total request - timeout. It can also be a pair (tuple) of - (connection, read) timeouts. + method: str, + url: str, + query_params: Optional[dict] = None, + headers: Optional[dict] = None, + body: Optional[Any] = None, + post_params: Optional[List[Tuple[str, Any]]] = None, + _preload_content: bool = True, + _request_timeout: Optional[float] = None, + ) -> dict: + """ + Builds a dictionary of request arguments suitable for aiohttp. + + :param method: The HTTP method. + :param url: The URL endpoint. + :param query_params: Optional query parameters. + :param headers: Optional request headers. + :param body: The request body, if any. + :param post_params: Form or multipart parameters, if any. + :param _preload_content: If True, content will be loaded immediately (not used here). + :param _request_timeout: Request timeout in seconds. + :return: A dictionary of request arguments. """ method = method.upper() assert method in ["GET", "HEAD", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"] @@ -116,14 +140,19 @@ async def request( "body parameter cannot be used with post_params parameter." ) - post_params = post_params or {} + post_params = post_params or [] headers = headers or {} - timeout = _request_timeout or self._timeout_millisec / 1000 + timeout = _request_timeout or (self._timeout_millisec / 1000) if "Content-Type" not in headers: headers["Content-Type"] = "application/json" - args = {"method": method, "url": url, "timeout": timeout, "headers": headers} + args = { + "method": method, + "url": url, + "timeout": timeout, + "headers": headers, + } if self.proxy: args["proxy"] = self.proxy @@ -133,7 +162,6 @@ async def request( if query_params: args["url"] += "?" + urllib.parse.urlencode(query_params) - # For `POST`, `PUT`, `PATCH`, `OPTIONS`, `DELETE` if method in ["POST", "PUT", "PATCH", "OPTIONS", "DELETE"]: if re.search("json", headers["Content-Type"], re.IGNORECASE): if body is not None: @@ -142,8 +170,6 @@ async def request( elif headers["Content-Type"] == "application/x-www-form-urlencoded": args["data"] = aiohttp.FormData(post_params) elif headers["Content-Type"] == "multipart/form-data": - # must del headers['Content-Type'], or the correct - # Content-Type which generated by aiohttp del headers["Content-Type"] data = aiohttp.FormData() for param in post_params: @@ -153,184 +179,192 @@ async def request( else: data.add_field(k, v) args["data"] = data - - # Pass a `bytes` parameter directly in the body to support - # other content types than Json when `body` argument is provided - # in serialized form elif isinstance(body, bytes): args["data"] = body else: - # Cannot generate the request from given parameters - msg = """Cannot prepare a request message for provided - arguments. Please check that your arguments match - declared content type.""" + msg = ( + "Cannot prepare a request message for provided arguments. " + "Please check that your arguments match declared content type." + ) raise ApiException(status=0, reason=msg) - r = await self.pool_manager.request(**args) - if _preload_content: - - data = await r.read() - r = RESTResponse(r, data) - - # log response body - logger.debug("response body: %s", r.data) - - if not 200 <= r.status <= 299: - if r.status == 400: - raise ValidationException(http_resp=r) - - if r.status == 401: - raise UnauthorizedException(http_resp=r) - - if r.status == 403: - raise ForbiddenException(http_resp=r) + return args - if r.status == 404: - raise NotFoundException(http_resp=r) - - if r.status == 429: - raise RateLimitExceededError(http_resp=r) - - if 500 <= r.status <= 599: - raise ServiceException(http_resp=r) - - raise ApiException(http_resp=r) - - return r + async def handle_response_exception( + self, response: RESTResponse | aiohttp.ClientResponse + ) -> None: + """ + Raises exceptions if response status indicates an error. + + :param response: The response to check. + :raises ValidationException: If status is 400. + :raises UnauthorizedException: If status is 401. + :raises ForbiddenException: If status is 403. + :raises NotFoundException: If status is 404. + :raises RateLimitExceededError: If status is 429. + :raises ServiceException: If status is 5xx. + :raises ApiException: For other non-2xx statuses. + """ + if 200 <= response.status <= 299: + return + + match response.status: + case 400: + raise ValidationException(http_resp=response) + case 401: + raise UnauthorizedException(http_resp=response) + case 403: + raise ForbiddenException(http_resp=response) + case 404: + raise NotFoundException(http_resp=response) + case 429: + raise RateLimitExceededError(http_resp=response) + case _ if 500 <= response.status <= 599: + raise ServiceException(http_resp=response) + case _: + raise ApiException(http_resp=response) + + def _accumulate_json_lines( + self, leftover: bytes, data: bytes, buffer: bytearray + ) -> Tuple[bytes, List[Any]]: + """ + Processes a chunk of data and leftover bytes. Splits on newlines, decodes valid JSON, + and returns leftover bytes and a list of decoded JSON objects. - async def GET( + :param leftover: Any leftover bytes from previous chunks. + :param data: The new chunk of data. + :param buffer: The main bytearray buffer for all data. + :return: Updated leftover bytes and a list of decoded JSON objects. + """ + objects: List[Any] = [] + leftover += data + lines = leftover.split(b"\n") + leftover = lines.pop() + buffer.extend(data) + for line in lines: + try: + decoded = json.loads(line.decode("utf-8")) + objects.append(decoded) + except json.JSONDecodeError as e: + logger.warning("Skipping invalid JSON segment: %s", e) + return leftover, objects + + async def stream( self, - url, - headers=None, - query_params=None, - _preload_content=True, - _request_timeout=None, + method: str, + url: str, + query_params: Optional[dict] = None, + headers: Optional[dict] = None, + body: Optional[Any] = None, + post_params: Optional[List[Tuple[str, Any]]] = None, + _request_timeout: Optional[float] = None, ): - return await self.request( - "GET", + """ + Streams JSON objects from a specified endpoint, handling partial chunks + and leftover data at the end of the stream. + + :param method: The HTTP method (GET, POST, etc.). + :param url: The endpoint URL. + :param query_params: Query parameters to be appended to the URL. + :param headers: Optional headers to include in the request. + :param body: Optional body for the request. + :param post_params: Optional form/multipart parameters. + :param _request_timeout: An optional request timeout in seconds. + :yields: Parsed JSON objects as Python data structures. + """ + args = await self.build_request( + method, url, - headers=headers, - _preload_content=_preload_content, - _request_timeout=_request_timeout, query_params=query_params, - ) - - async def HEAD( - self, - url, - headers=None, - query_params=None, - _preload_content=True, - _request_timeout=None, - ): - return await self.request( - "HEAD", - url, headers=headers, - _preload_content=_preload_content, - _request_timeout=_request_timeout, - query_params=query_params, - ) - - async def OPTIONS( - self, - url, - headers=None, - query_params=None, - post_params=None, - body=None, - _preload_content=True, - _request_timeout=None, - ): - return await self.request( - "OPTIONS", - url, - headers=headers, - query_params=query_params, + body=body, post_params=post_params, - _preload_content=_preload_content, + _preload_content=False, _request_timeout=_request_timeout, - body=body, ) - async def DELETE( - self, - url, - headers=None, - query_params=None, - body=None, - _preload_content=True, - _request_timeout=None, - ): - return await self.request( - "DELETE", - url, - headers=headers, - query_params=query_params, - _preload_content=_preload_content, - _request_timeout=_request_timeout, - body=body, - ) + buffer = bytearray() + leftover = b"" + response: Optional[aiohttp.ClientResponse] = None + + try: + async with self.pool_manager.request(**args) as resp: + response = resp + try: + async for data, _ in resp.content.iter_chunks(): + if data: + leftover, decoded_objects = self._accumulate_json_lines( + leftover, data, buffer + ) + for obj in decoded_objects: + yield obj + + except Exception as e: + logger.exception("Stream reading error: %s", e) + + except Exception as conn_err: + logger.exception("Connection or request setup error: %s", conn_err) + + if response is not None: + if leftover: + try: + final_str = leftover.decode("utf-8") + final_obj = json.loads(final_str) + buffer.extend(leftover) + yield final_obj + except json.JSONDecodeError: + logger.debug("Incomplete leftover data at end of stream.") + + if isinstance(response, aiohttp.ClientResponse): + response.data = buffer.decode("utf-8") + + await self.handle_response_exception(response) + response.release() + + await self.close() - async def POST( + async def request( self, - url, - headers=None, - query_params=None, - post_params=None, - body=None, - _preload_content=True, - _request_timeout=None, - ): - return await self.request( - "POST", + method: str, + url: str, + query_params: Optional[dict] = None, + headers: Optional[dict] = None, + body: Optional[Any] = None, + post_params: Optional[List[Tuple[str, Any]]] = None, + _preload_content: bool = True, + _request_timeout: Optional[float] = None, + ) -> RESTResponse | aiohttp.ClientResponse: + """ + Executes a request and returns the response object. + + :param method: The HTTP method. + :param url: The endpoint URL. + :param query_params: Query parameters to be appended to the URL. + :param headers: Optional request headers. + :param body: A request body for JSON or other content types. + :param post_params: Form/multipart parameters for the request. + :param _preload_content: If True, the response body is read immediately. + :param _request_timeout: An optional request timeout in seconds. + :return: A RESTResponse if _preload_content is True, otherwise an aiohttp.ClientResponse. + """ + args = await self.build_request( + method, url, - headers=headers, query_params=query_params, - post_params=post_params, - _preload_content=_preload_content, - _request_timeout=_request_timeout, - body=body, - ) - - async def PUT( - self, - url, - headers=None, - query_params=None, - post_params=None, - body=None, - _preload_content=True, - _request_timeout=None, - ): - return await self.request( - "PUT", - url, headers=headers, - query_params=query_params, - post_params=post_params, - _preload_content=_preload_content, - _request_timeout=_request_timeout, body=body, - ) - - async def PATCH( - self, - url, - headers=None, - query_params=None, - post_params=None, - body=None, - _preload_content=True, - _request_timeout=None, - ): - return await self.request( - "PATCH", - url, - headers=headers, - query_params=query_params, post_params=post_params, _preload_content=_preload_content, _request_timeout=_request_timeout, - body=body, ) + + resp = await self.pool_manager.request(**args) + + if _preload_content: + data = await resp.read() + resp = RESTResponse(resp, data) + logger.debug(f"response body: {resp.data}") + + await self.handle_response_exception(resp) + + return resp diff --git a/openfga_sdk/sync/api_client.py b/openfga_sdk/sync/api_client.py index fe908c3..51a898b 100644 --- a/openfga_sdk/sync/api_client.py +++ b/openfga_sdk/sync/api_client.py @@ -165,6 +165,7 @@ def __call_api( _retry_params=None, _oauth2_client=None, _telemetry_attributes: dict[TelemetryAttribute, str | int] = None, + _streaming: bool = False, ): self.configuration.is_valid() @@ -279,6 +280,7 @@ def __call_api( body=body, _preload_content=_preload_content, _request_timeout=_request_timeout, + _streaming=_streaming, ) except (RateLimitExceededError, ServiceException) as e: if retry < max_retry and e.status != 501: @@ -360,7 +362,7 @@ def __call_api( configuration=self.configuration.telemetry, ) - if not _preload_content: + if not _preload_content or _streaming: return return_data response_type = response_types_map.get(response_data.status, None) @@ -506,6 +508,7 @@ def call_api( _retry_params=None, _oauth2_client=None, _telemetry_attributes: dict[TelemetryAttribute, str | int] = None, + _streaming: bool = False, ): """Makes the HTTP request (synchronous) and returns deserialized data. @@ -567,6 +570,7 @@ def call_api( _retry_params, _oauth2_client, _telemetry_attributes, + _streaming, ) return self.pool.apply_async( @@ -590,6 +594,7 @@ def call_api( _retry_params, _oauth2_client, _telemetry_attributes, + _streaming, ), ) @@ -603,77 +608,36 @@ def request( body=None, _preload_content=True, _request_timeout=None, + _streaming: bool = False, ): - """Makes the HTTP request using RESTClient.""" - if method == "GET": - return self.rest_client.GET( - url, - query_params=query_params, - _preload_content=_preload_content, - _request_timeout=_request_timeout, - headers=headers, - ) - elif method == "HEAD": - return self.rest_client.HEAD( - url, - query_params=query_params, - _preload_content=_preload_content, - _request_timeout=_request_timeout, - headers=headers, - ) - elif method == "OPTIONS": - return self.rest_client.OPTIONS( - url, - query_params=query_params, - headers=headers, - _preload_content=_preload_content, - _request_timeout=_request_timeout, - ) - elif method == "POST": - return self.rest_client.POST( - url, - query_params=query_params, - headers=headers, - post_params=post_params, - _preload_content=_preload_content, - _request_timeout=_request_timeout, - body=body, - ) - elif method == "PUT": - return self.rest_client.PUT( - url, - query_params=query_params, - headers=headers, - post_params=post_params, - _preload_content=_preload_content, - _request_timeout=_request_timeout, - body=body, + if method not in ["GET", "HEAD", "OPTIONS", "POST", "PATCH", "PUT", "DELETE"]: + raise ApiValueError( + "http method must be `GET`, `HEAD`, `OPTIONS`," + " `POST`, `PATCH`, `PUT` or `DELETE`." ) - elif method == "PATCH": - return self.rest_client.PATCH( + + if _streaming: + return self.rest_client.stream( + method, url, query_params=query_params, headers=headers, post_params=post_params, - _preload_content=_preload_content, - _request_timeout=_request_timeout, body=body, - ) - elif method == "DELETE": - return self.rest_client.DELETE( - url, - query_params=query_params, - headers=headers, - _preload_content=_preload_content, _request_timeout=_request_timeout, - body=body, - ) - else: - raise ApiValueError( - "http method must be `GET`, `HEAD`, `OPTIONS`," - " `POST`, `PATCH`, `PUT` or `DELETE`." ) + return self.rest_client.request( + method, + url, + query_params=query_params, + headers=headers, + post_params=post_params, + body=body, + _preload_content=_preload_content, + _request_timeout=_request_timeout, + ) + def parameters_to_tuples(self, params, collection_formats): """Get parameters as list of tuples, formatting collections. diff --git a/openfga_sdk/sync/client/client.py b/openfga_sdk/sync/client/client.py index 39022c6..f0313d6 100644 --- a/openfga_sdk/sync/client/client.py +++ b/openfga_sdk/sync/client/client.py @@ -62,6 +62,9 @@ ) from openfga_sdk.models.read_request import ReadRequest from openfga_sdk.models.read_request_tuple_key import ReadRequestTupleKey +from openfga_sdk.models.streamed_list_objects_response import ( + StreamedListObjectsResponse, +) from openfga_sdk.models.tuple_key import TupleKey from openfga_sdk.models.write_assertions_request import WriteAssertionsRequest from openfga_sdk.models.write_authorization_model_request import ( @@ -806,6 +809,43 @@ def list_objects( api_response = self._api.list_objects(body=req_body, **kwargs) return api_response + def streamed_list_objects( + self, body: ClientListObjectsRequest, options: dict[str, str] = None + ): + """ + Retrieve all objects of the given type that the user has a relation with, using the streaming ListObjects API. + + :param body - list object parameters + :param authorization_model_id(options) - Overrides the authorization model id in the configuration + :param header(options) - Custom headers to send alongside the request + :param retryParams(options) - Override the retry parameters for this request + :param retryParams.maxRetry(options) - Override the max number of retries on each API request + :param retryParams.minWaitInMs(options) - Override the minimum wait before a retry is initiated + :param consistency(options) - The type of consistency preferred for the request + """ + kwargs = options_to_kwargs(options) + kwargs["_streaming"] = True + + req_body = ListObjectsRequest( + authorization_model_id=self._get_authorization_model_id(options), + user=body.user, + relation=body.relation, + type=body.type, + context=body.context, + consistency=self._get_consistency(options), + ) + + if body.contextual_tuples: + req_body.contextual_tuples = ContextualTupleKeys( + tuple_keys=convert_tuple_keys(body.contextual_tuples) + ) + + for response in self._api.streamed_list_objects(body=req_body, **kwargs): + if response and "result" in response and "object" in response["result"]: + yield StreamedListObjectsResponse(response["result"]["object"]) + + return + def list_relations( self, body: ClientListRelationsRequest, options: dict[str, str] = None ): diff --git a/openfga_sdk/sync/oauth2.py b/openfga_sdk/sync/oauth2.py index b6f73e9..a666b05 100644 --- a/openfga_sdk/sync/oauth2.py +++ b/openfga_sdk/sync/oauth2.py @@ -106,8 +106,15 @@ def _obtain_token(self, client): ) for attempt in range(max_retry + 1): - raw_response = client.POST( - token_url, headers=headers, post_params=post_params + raw_response = client.request( + method="POST", + url=token_url, + headers=headers, + query_params=None, + body=None, + _preload_content=True, + _request_timeout=None, + post_params=post_params, ) if 500 <= raw_response.status <= 599 or raw_response.status == 429: diff --git a/openfga_sdk/sync/open_fga_api.py b/openfga_sdk/sync/open_fga_api.py index 928430d..a34ef4b 100644 --- a/openfga_sdk/sync/open_fga_api.py +++ b/openfga_sdk/sync/open_fga_api.py @@ -120,6 +120,7 @@ def batch_check_with_http_info(self, body, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -224,6 +225,7 @@ def batch_check_with_http_info(self, body, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) def check(self, body, **kwargs): @@ -300,6 +302,7 @@ def check_with_http_info(self, body, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -403,6 +406,7 @@ def check_with_http_info(self, body, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) def create_store(self, body, **kwargs): @@ -479,6 +483,7 @@ def create_store_with_http_info(self, body, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -569,6 +574,7 @@ def create_store_with_http_info(self, body, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) def delete_store(self, **kwargs): @@ -641,6 +647,7 @@ def delete_store_with_http_info(self, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -716,12 +723,13 @@ def delete_store_with_http_info(self, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) def expand(self, body, **kwargs): """Expand all relationships in userset tree format, and following userset rewrite rules. Useful to reason about and debug a certain relationship - The Expand API will return all users and usersets that have certain relationship with an object in a certain store. This is different from the `/stores/{store_id}/read` API in that both users and computed usersets are returned. Body parameters `tuple_key.object` and `tuple_key.relation` are all required. The response will return a tree whose leaves are the specific users and usersets. Union, intersection and difference operator are located in the intermediate nodes. ## Example To expand all users that have the `reader` relationship with object `document:2021-budget`, use the Expand API with the following request body ```json { \"tuple_key\": { \"object\": \"document:2021-budget\", \"relation\": \"reader\" }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` OpenFGA's response will be a userset tree of the users and usersets that have read access to the document. ```json { \"tree\":{ \"root\":{ \"type\":\"document:2021-budget#reader\", \"union\":{ \"nodes\":[ { \"type\":\"document:2021-budget#reader\", \"leaf\":{ \"users\":{ \"users\":[ \"user:bob\" ] } } }, { \"type\":\"document:2021-budget#reader\", \"leaf\":{ \"computed\":{ \"userset\":\"document:2021-budget#writer\" } } } ] } } } } ``` The caller can then call expand API for the `writer` relationship for the `document:2021-budget`. + The Expand API will return all users and usersets that have certain relationship with an object in a certain store. This is different from the `/stores/{store_id}/read` API in that both users and computed usersets are returned. Body parameters `tuple_key.object` and `tuple_key.relation` are all required. A `contextual_tuples` object may also be included in the body of the request. This object contains one field `tuple_keys`, which is an array of tuple keys. Each of these tuples may have an associated `condition`. The response will return a tree whose leaves are the specific users and usersets. Union, intersection and difference operator are located in the intermediate nodes. ## Example To expand all users that have the `reader` relationship with object `document:2021-budget`, use the Expand API with the following request body ```json { \"tuple_key\": { \"object\": \"document:2021-budget\", \"relation\": \"reader\" }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` OpenFGA's response will be a userset tree of the users and usersets that have read access to the document. ```json { \"tree\":{ \"root\":{ \"type\":\"document:2021-budget#reader\", \"union\":{ \"nodes\":[ { \"type\":\"document:2021-budget#reader\", \"leaf\":{ \"users\":{ \"users\":[ \"user:bob\" ] } } }, { \"type\":\"document:2021-budget#reader\", \"leaf\":{ \"computed\":{ \"userset\":\"document:2021-budget#writer\" } } } ] } } } } ``` The caller can then call expand API for the `writer` relationship for the `document:2021-budget`. ### Expand Request with Contextual Tuples Given the model ```python model schema 1.1 type user type folder relations define owner: [user] type document relations define parent: [folder] define viewer: [user] or writer define writer: [user] or owner from parent ``` and the initial tuples ```json [{ \"user\": \"user:bob\", \"relation\": \"owner\", \"object\": \"folder:1\" }] ``` To expand all `writers` of `document:1` when `document:1` is put in `folder:1`, the first call could be ```json { \"tuple_key\": { \"object\": \"document:1\", \"relation\": \"writer\" }, \"contextual_tuples\": { \"tuple_keys\": [ { \"user\": \"folder:1\", \"relation\": \"parent\", \"object\": \"document:1\" } ] } } ``` this returns: ```json { \"tree\": { \"root\": { \"name\": \"document:1#writer\", \"union\": { \"nodes\": [ { \"name\": \"document:1#writer\", \"leaf\": { \"users\": { \"users\": [] } } }, { \"name\": \"document:1#writer\", \"leaf\": { \"tupleToUserset\": { \"tupleset\": \"document:1#parent\", \"computed\": [ { \"userset\": \"folder:1#owner\" } ] } } } ] } } } } ``` This tells us that the `owner` of `folder:1` may also be a writer. So our next call could be to find the `owners` of `folder:1` ```json { \"tuple_key\": { \"object\": \"folder:1\", \"relation\": \"owner\" } } ``` which gives ```json { \"tree\": { \"root\": { \"name\": \"folder:1#owner\", \"leaf\": { \"users\": { \"users\": [ \"user:bob\" ] } } } } } ``` >>> thread = api.expand(body) @@ -748,7 +756,7 @@ def expand(self, body, **kwargs): def expand_with_http_info(self, body, **kwargs): """Expand all relationships in userset tree format, and following userset rewrite rules. Useful to reason about and debug a certain relationship - The Expand API will return all users and usersets that have certain relationship with an object in a certain store. This is different from the `/stores/{store_id}/read` API in that both users and computed usersets are returned. Body parameters `tuple_key.object` and `tuple_key.relation` are all required. The response will return a tree whose leaves are the specific users and usersets. Union, intersection and difference operator are located in the intermediate nodes. ## Example To expand all users that have the `reader` relationship with object `document:2021-budget`, use the Expand API with the following request body ```json { \"tuple_key\": { \"object\": \"document:2021-budget\", \"relation\": \"reader\" }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` OpenFGA's response will be a userset tree of the users and usersets that have read access to the document. ```json { \"tree\":{ \"root\":{ \"type\":\"document:2021-budget#reader\", \"union\":{ \"nodes\":[ { \"type\":\"document:2021-budget#reader\", \"leaf\":{ \"users\":{ \"users\":[ \"user:bob\" ] } } }, { \"type\":\"document:2021-budget#reader\", \"leaf\":{ \"computed\":{ \"userset\":\"document:2021-budget#writer\" } } } ] } } } } ``` The caller can then call expand API for the `writer` relationship for the `document:2021-budget`. + The Expand API will return all users and usersets that have certain relationship with an object in a certain store. This is different from the `/stores/{store_id}/read` API in that both users and computed usersets are returned. Body parameters `tuple_key.object` and `tuple_key.relation` are all required. A `contextual_tuples` object may also be included in the body of the request. This object contains one field `tuple_keys`, which is an array of tuple keys. Each of these tuples may have an associated `condition`. The response will return a tree whose leaves are the specific users and usersets. Union, intersection and difference operator are located in the intermediate nodes. ## Example To expand all users that have the `reader` relationship with object `document:2021-budget`, use the Expand API with the following request body ```json { \"tuple_key\": { \"object\": \"document:2021-budget\", \"relation\": \"reader\" }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` OpenFGA's response will be a userset tree of the users and usersets that have read access to the document. ```json { \"tree\":{ \"root\":{ \"type\":\"document:2021-budget#reader\", \"union\":{ \"nodes\":[ { \"type\":\"document:2021-budget#reader\", \"leaf\":{ \"users\":{ \"users\":[ \"user:bob\" ] } } }, { \"type\":\"document:2021-budget#reader\", \"leaf\":{ \"computed\":{ \"userset\":\"document:2021-budget#writer\" } } } ] } } } } ``` The caller can then call expand API for the `writer` relationship for the `document:2021-budget`. ### Expand Request with Contextual Tuples Given the model ```python model schema 1.1 type user type folder relations define owner: [user] type document relations define parent: [folder] define viewer: [user] or writer define writer: [user] or owner from parent ``` and the initial tuples ```json [{ \"user\": \"user:bob\", \"relation\": \"owner\", \"object\": \"folder:1\" }] ``` To expand all `writers` of `document:1` when `document:1` is put in `folder:1`, the first call could be ```json { \"tuple_key\": { \"object\": \"document:1\", \"relation\": \"writer\" }, \"contextual_tuples\": { \"tuple_keys\": [ { \"user\": \"folder:1\", \"relation\": \"parent\", \"object\": \"document:1\" } ] } } ``` this returns: ```json { \"tree\": { \"root\": { \"name\": \"document:1#writer\", \"union\": { \"nodes\": [ { \"name\": \"document:1#writer\", \"leaf\": { \"users\": { \"users\": [] } } }, { \"name\": \"document:1#writer\", \"leaf\": { \"tupleToUserset\": { \"tupleset\": \"document:1#parent\", \"computed\": [ { \"userset\": \"folder:1#owner\" } ] } } } ] } } } } ``` This tells us that the `owner` of `folder:1` may also be a writer. So our next call could be to find the `owners` of `folder:1` ```json { \"tuple_key\": { \"object\": \"folder:1\", \"relation\": \"owner\" } } ``` which gives ```json { \"tree\": { \"root\": { \"name\": \"folder:1#owner\", \"leaf\": { \"users\": { \"users\": [ \"user:bob\" ] } } } } } ``` >>> thread = api.expand_with_http_info(body) @@ -792,6 +800,7 @@ def expand_with_http_info(self, body, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -895,6 +904,7 @@ def expand_with_http_info(self, body, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) def get_store(self, **kwargs): @@ -967,6 +977,7 @@ def get_store_with_http_info(self, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -1051,6 +1062,7 @@ def get_store_with_http_info(self, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) def list_objects(self, body, **kwargs): @@ -1127,6 +1139,7 @@ def list_objects_with_http_info(self, body, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -1231,6 +1244,7 @@ def list_objects_with_http_info(self, body, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) def list_stores(self, **kwargs): @@ -1311,6 +1325,7 @@ def list_stores_with_http_info(self, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -1395,6 +1410,7 @@ def list_stores_with_http_info(self, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) def list_users(self, body, **kwargs): @@ -1471,6 +1487,7 @@ def list_users_with_http_info(self, body, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -1575,6 +1592,7 @@ def list_users_with_http_info(self, body, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) def read(self, body, **kwargs): @@ -1651,6 +1669,7 @@ def read_with_http_info(self, body, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -1754,6 +1773,7 @@ def read_with_http_info(self, body, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) def read_assertions(self, authorization_model_id, **kwargs): @@ -1830,6 +1850,7 @@ def read_assertions_with_http_info(self, authorization_model_id, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -1929,6 +1950,7 @@ def read_assertions_with_http_info(self, authorization_model_id, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) def read_authorization_model(self, id, **kwargs): @@ -2005,6 +2027,7 @@ def read_authorization_model_with_http_info(self, id, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -2102,6 +2125,7 @@ def read_authorization_model_with_http_info(self, id, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) def read_authorization_models(self, **kwargs): @@ -2182,6 +2206,7 @@ def read_authorization_models_with_http_info(self, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -2272,6 +2297,7 @@ def read_authorization_models_with_http_info(self, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) def read_changes(self, **kwargs): @@ -2360,6 +2386,7 @@ def read_changes_with_http_info(self, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -2454,6 +2481,189 @@ def read_changes_with_http_info(self, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), + ) + + def streamed_list_objects(self, body, **kwargs): + """Stream all objects of the given type that the user has a relation with + + The Streamed ListObjects API is very similar to the the ListObjects API, with two differences: 1. Instead of collecting all objects before returning a response, it streams them to the client as they are collected. 2. The number of results returned is only limited by the execution timeout specified in the flag OPENFGA_LIST_OBJECTS_DEADLINE. + + >>> thread = api.streamed_list_objects(body) + + :param body: (required) + :type body: ListObjectsRequest + :param async_req: Whether to execute the request asynchronously. + :type async_req: bool, optional + :param _preload_content: if False, the urllib3.HTTPResponse object will + be returned without reading/decoding response + data. Default is True. + :type _preload_content: bool, optional + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :return: Returns the result object. + If the method is called asynchronously, + returns the request thread. + :rtype: StreamResultOfStreamedListObjectsResponse + """ + kwargs["_return_http_data_only"] = True + return self.streamed_list_objects_with_http_info(body, **kwargs) + + def streamed_list_objects_with_http_info(self, body, **kwargs): + """Stream all objects of the given type that the user has a relation with + + The Streamed ListObjects API is very similar to the the ListObjects API, with two differences: 1. Instead of collecting all objects before returning a response, it streams them to the client as they are collected. 2. The number of results returned is only limited by the execution timeout specified in the flag OPENFGA_LIST_OBJECTS_DEADLINE. + + >>> thread = api.streamed_list_objects_with_http_info(body) + + :param body: (required) + :type body: ListObjectsRequest + :param async_req: Whether to execute the request asynchronously. + :type async_req: bool, optional + :param _return_http_data_only: response data without head status code + and headers + :type _return_http_data_only: bool, optional + :param _preload_content: if False, the urllib3.HTTPResponse object will + be returned without reading/decoding response + data. Default is True. + :type _preload_content: bool, optional + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the authentication + in the spec for a single request. + :param _retry_param: if specified, override the retry parameters specified in configuration + :type _request_auth: dict, optional + :type _content_type: string, optional: force content-type for the request + :return: Returns the result object. + If the method is called asynchronously, + returns the request thread. + :rtype: tuple(StreamResultOfStreamedListObjectsResponse, status_code(int), headers(HTTPHeaderDict)) + """ + + local_var_params = locals() + + all_params = ["body"] + all_params.extend( + [ + "async_req", + "_return_http_data_only", + "_preload_content", + "_request_timeout", + "_request_auth", + "_content_type", + "_headers", + "_retry_params", + "_streaming", + ] + ) + + for key, val in local_var_params["kwargs"].items(): + if key not in all_params: + raise FgaValidationException( + "Got an unexpected keyword argument '%s'" + " to method streamed_list_objects" % key + ) + local_var_params[key] = val + del local_var_params["kwargs"] + # verify the required parameter 'body' is set + if ( + self.api_client.client_side_validation + and local_var_params.get("body") is None + ): + raise ApiValueError( + "Missing the required parameter `body` when calling `streamed_list_objects`" + ) + + collection_formats = {} + + path_params = {} + + store_id = None + + if self.api_client._get_store_id() is None: + raise ApiValueError( + "Store ID expected in api_client's configuration when calling `streamed_list_objects`" + ) + store_id = self.api_client._get_store_id() + + query_params = [] + + header_params = dict(local_var_params.get("_headers", {})) + + form_params = [] + local_var_files = {} + + body_params = None + if "body" in local_var_params: + body_params = local_var_params["body"] + # HTTP header `Accept` + header_params["Accept"] = self.api_client.select_header_accept( + ["application/json"] + ) + + # HTTP header `Content-Type` + content_types_list = local_var_params.get( + "_content_type", + self.api_client.select_header_content_type( + ["application/json"], "POST", body_params + ), + ) + if content_types_list: + header_params["Content-Type"] = content_types_list + + # Authentication setting + auth_settings = [] + + response_types_map = { + 200: "StreamResultOfStreamedListObjectsResponse", + 400: "ValidationErrorMessageResponse", + 401: "UnauthenticatedResponse", + 403: "ForbiddenResponse", + 404: "PathUnknownErrorMessageResponse", + 409: "AbortedMessageResponse", + 422: "UnprocessableContentMessageResponse", + 500: "InternalErrorMessageResponse", + } + + telemetry_attributes: dict[TelemetryAttribute, str | int] = { + TelemetryAttributes.fga_client_request_method: "streamed_list_objects", + TelemetryAttributes.fga_client_request_store_id: self.api_client.get_store_id(), + TelemetryAttributes.fga_client_request_model_id: local_var_params.get( + "authorization_model_id", "" + ), + } + + telemetry_attributes = TelemetryAttributes.fromBody( + body=body_params, + attributes=telemetry_attributes, + ) + + return self.api_client.call_api( + "/stores/{store_id}/streamed-list-objects".replace("{store_id}", store_id), + "POST", + path_params, + query_params, + header_params, + body=body_params, + post_params=form_params, + files=local_var_files, + response_types_map=response_types_map, + auth_settings=auth_settings, + async_req=local_var_params.get("async_req"), + _return_http_data_only=local_var_params.get("_return_http_data_only"), + _preload_content=local_var_params.get("_preload_content", True), + _request_timeout=local_var_params.get("_request_timeout"), + _retry_params=local_var_params.get("_retry_params"), + collection_formats=collection_formats, + _request_auth=local_var_params.get("_request_auth"), + _oauth2_client=self._oauth2_client, + _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) def write(self, body, **kwargs): @@ -2530,6 +2740,7 @@ def write_with_http_info(self, body, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -2633,6 +2844,7 @@ def write_with_http_info(self, body, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) def write_assertions(self, authorization_model_id, body, **kwargs): @@ -2715,6 +2927,7 @@ def write_assertions_with_http_info(self, authorization_model_id, body, **kwargs "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -2825,6 +3038,7 @@ def write_assertions_with_http_info(self, authorization_model_id, body, **kwargs _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) def write_authorization_model(self, body, **kwargs): @@ -2901,6 +3115,7 @@ def write_authorization_model_with_http_info(self, body, **kwargs): "_content_type", "_headers", "_retry_params", + "_streaming", ] ) @@ -3005,4 +3220,5 @@ def write_authorization_model_with_http_info(self, body, **kwargs): _request_auth=local_var_params.get("_request_auth"), _oauth2_client=self._oauth2_client, _telemetry_attributes=telemetry_attributes, + _streaming=local_var_params.get("_streaming", False), ) diff --git a/openfga_sdk/sync/rest.py b/openfga_sdk/sync/rest.py index caab5ef..1becb88 100644 --- a/openfga_sdk/sync/rest.py +++ b/openfga_sdk/sync/rest.py @@ -16,6 +16,7 @@ import re import ssl import urllib +from typing import Any, List, Optional, Tuple import urllib3 @@ -34,57 +35,88 @@ class RESTResponse(io.IOBase): + """ + Represents an HTTP response object in the non-async client. + """ - def __init__(self, resp, data): + def __init__(self, resp: urllib3.HTTPResponse, data: bytes) -> None: + """ + Initializes a RESTResponse with a urllib3.HTTPResponse and corresponding data. + + :param resp: The urllib3.HTTPResponse object. + :param data: The raw byte data read from the response. + """ self.urllib3_response = resp self.status = resp.status self.reason = resp.reason self.data = data - def getheaders(self): - """Returns a dictionary of the response headers.""" + def getheaders(self) -> dict: + """ + Returns a dictionary of the response headers. + """ return self.urllib3_response.headers - def getheader(self, name, default=None): - """Returns a given response header.""" + def getheader(self, name: str, default: Optional[str] = None) -> Optional[str]: + """ + Returns a specific header value by name. + + :param name: The name of the header. + :param default: The default value if header is not found. + :return: The header value, or default if not present. + """ return self.urllib3_response.headers.get(name, default) class RESTClientObject: + """ + A synchronous client object that manages HTTP interactions using urllib3. + """ - def __init__(self, configuration, pools_size=4, maxsize=None): - # urllib3.PoolManager will pass all kw parameters to connectionpool - # https://github.com/shazow/urllib3/blob/f9409436f83aeb79fbaf090181cd81b784f1b8ce/urllib3/poolmanager.py#L75 - # https://github.com/shazow/urllib3/blob/f9409436f83aeb79fbaf090181cd81b784f1b8ce/urllib3/connectionpool.py#L680 - # maxsize is the number of requests to host that are allowed in parallel - # Custom SSL certificates and client certificates: http://urllib3.readthedocs.io/en/latest/advanced-usage.html + def __init__( + self, configuration: Any, pools_size: int = 4, maxsize: Optional[int] = None + ) -> None: + """ + Creates a new RESTClientObject using urllib3. - # cert_reqs - if configuration.verify_ssl: + :param configuration: A configuration object with necessary parameters. + :param pools_size: The number of connection pools to use. + :param maxsize: The maximum number of connections per pool. + """ + if hasattr(configuration, "verify_ssl") and configuration.verify_ssl: cert_reqs = ssl.CERT_REQUIRED else: cert_reqs = ssl.CERT_NONE addition_pool_args = {} - if configuration.assert_hostname is not None: + + if ( + hasattr(configuration, "assert_hostname") + and configuration.assert_hostname is not None + ): addition_pool_args["assert_hostname"] = configuration.assert_hostname - if configuration.retries is not None: + if hasattr(configuration, "retries") and configuration.retries is not None: addition_pool_args["retries"] = configuration.retries - if configuration.socket_options is not None: + if ( + hasattr(configuration, "socket_options") + and configuration.socket_options is not None + ): addition_pool_args["socket_options"] = configuration.socket_options if maxsize is None: - if configuration.connection_pool_maxsize is not None: + if ( + hasattr(configuration, "connection_pool_maxsize") + and configuration.connection_pool_maxsize is not None + ): maxsize = configuration.connection_pool_maxsize else: maxsize = 4 self._timeout_millisec = configuration.timeout_millisec - # https pool manager - if configuration.proxy: + if hasattr(configuration, "proxy") and configuration.proxy is not None: self.pool_manager = urllib3.ProxyManager( num_pools=pools_size, maxsize=maxsize, @@ -96,48 +128,48 @@ def __init__(self, configuration, pools_size=4, maxsize=None): proxy_headers=configuration.proxy_headers, **addition_pool_args, ) - else: - self.pool_manager = urllib3.PoolManager( - num_pools=pools_size, - maxsize=maxsize, - cert_reqs=cert_reqs, - ca_certs=configuration.ssl_ca_cert, - cert_file=configuration.cert_file, - key_file=configuration.key_file, - **addition_pool_args, - ) - def close(self): + return + + self.pool_manager = urllib3.PoolManager( + num_pools=pools_size, + maxsize=maxsize, + cert_reqs=cert_reqs, + ca_certs=configuration.ssl_ca_cert, + cert_file=configuration.cert_file, + key_file=configuration.key_file, + **addition_pool_args, + ) + + def close(self) -> None: + """ + Closes all pooled connections. + """ self.pool_manager.clear() - def request( + def build_request( self, - method, - url, - query_params=None, - headers=None, - body=None, - post_params=None, - _preload_content=True, - _request_timeout=None, - ): - """Perform requests. - - :param method: http request method - :param url: http request url - :param query_params: query parameters in the url - :param headers: http request headers - :param body: request json body, for `application/json` - :param post_params: request post parameters, - `application/x-www-form-urlencoded` - and `multipart/form-data` - :param _preload_content: if False, the urllib3.HTTPResponse object will - be returned without reading/decoding response - data. Default is True. - :param _request_timeout: timeout setting for this request. If one - number provided, it will be total request - timeout. It can also be a pair (tuple) of - (connection, read) timeouts. + method: str, + url: str, + query_params: Optional[dict] = None, + headers: Optional[dict] = None, + body: Optional[Any] = None, + post_params: Optional[dict] = None, + _preload_content: bool = True, + _request_timeout: Optional[float | tuple] = None, + ) -> dict: + """ + Builds a dictionary of request arguments suitable for urllib3. + + :param method: The HTTP method (GET, POST, etc.). + :param url: The URL endpoint. + :param query_params: Optional query parameters. + :param headers: Optional request headers. + :param body: The request body, if any. + :param post_params: Form or multipart parameters, if any. + :param _preload_content: If True, response data is read immediately (by urllib3). + :param _request_timeout: Timeout setting, in seconds or a (connect, read) tuple. + :return: A dictionary of request arguments for urllib3. """ method = method.upper() assert method in ["GET", "HEAD", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"] @@ -147,257 +179,234 @@ def request( "body parameter cannot be used with post_params parameter." ) - post_params = post_params or {} headers = headers or {} - - timeout = urllib3.Timeout(total=self._timeout_millisec / 1000) - if _request_timeout: - if isinstance(_request_timeout, (float, int)): - timeout = urllib3.Timeout(total=_request_timeout) - elif isinstance(_request_timeout, tuple) and len(_request_timeout) == 2: - timeout = urllib3.Timeout( - connect=_request_timeout[0], read=_request_timeout[1] - ) + post_params = post_params or {} + timeout_val = _request_timeout or self._timeout_millisec + + if isinstance(timeout_val, (float, int)): + if timeout_val > 100: + timeout_val /= 1000 + timeout = urllib3.Timeout(total=timeout_val) + elif isinstance(timeout_val, tuple) and len(timeout_val) == 2: + connect_t, read_t = timeout_val + if connect_t > 100: + connect_t /= 1000 + if read_t > 100: + read_t /= 1000 + timeout = urllib3.Timeout(connect=connect_t, read=read_t) + else: + timeout = urllib3.Timeout(total=None) # fallback if "Content-Type" not in headers: headers["Content-Type"] = "application/json" - try: - # For `POST`, `PUT`, `PATCH`, `OPTIONS`, `DELETE` - if method in ["POST", "PUT", "PATCH", "OPTIONS", "DELETE"]: - if query_params: - url += "?" + urllib.parse.urlencode(query_params) - if re.search("json", headers["Content-Type"], re.IGNORECASE): - request_body = None - if body is not None: - request_body = json.dumps(body) - r = self.pool_manager.request( - method, - url, - body=request_body, - preload_content=_preload_content, - timeout=timeout, - headers=headers, - ) - elif headers["Content-Type"] == "application/x-www-form-urlencoded": - r = self.pool_manager.request( - method, - url, - fields=post_params, - encode_multipart=False, - preload_content=_preload_content, - timeout=timeout, - headers=headers, - ) - elif headers["Content-Type"] == "multipart/form-data": - # must del headers['Content-Type'], or the correct - # Content-Type which generated by urllib3 will be - # overwritten. - del headers["Content-Type"] - r = self.pool_manager.request( - method, - url, - fields=post_params, - encode_multipart=True, - preload_content=_preload_content, - timeout=timeout, - headers=headers, - ) - # Pass a `string` parameter directly in the body to support - # other content types than Json when `body` argument is - # provided in serialized form - elif isinstance(body, str) or isinstance(body, bytes): - request_body = body - r = self.pool_manager.request( - method, - url, - body=request_body, - preload_content=_preload_content, - timeout=timeout, - headers=headers, - ) - else: - # Cannot generate the request from given parameters - msg = """Cannot prepare a request message for provided - arguments. Please check that your arguments match - declared content type.""" - raise ApiException(status=0, reason=msg) - # For `GET`, `HEAD` + args = { + "method": method, + "url": url, + "timeout": timeout, + "headers": headers, + "preload_content": _preload_content, + } + + if query_params: + encoded_qs = urllib.parse.urlencode(query_params) + args["url"] = f"{url}?{encoded_qs}" + + # Handle body/post_params for methods that send payloads + if method in ["POST", "PUT", "PATCH", "OPTIONS", "DELETE"]: + if re.search("json", headers["Content-Type"], re.IGNORECASE): + if body is not None: + body = json.dumps(body) + args["body"] = body + + elif headers["Content-Type"] == "application/x-www-form-urlencoded": + args["fields"] = post_params + args["encode_multipart"] = False + + elif headers["Content-Type"] == "multipart/form-data": + del headers["Content-Type"] + args["fields"] = post_params + args["encode_multipart"] = True + + elif isinstance(body, (str, bytes)): + args["body"] = body else: - r = self.pool_manager.request( - method, - url, - fields=query_params, - preload_content=_preload_content, - timeout=timeout, - headers=headers, + msg = ( + "Cannot prepare a request message for provided arguments. " + "Please check that your arguments match declared content type." ) - except urllib3.exceptions.SSLError as e: - msg = f"{type(e).__name__}\n{str(e)}" - raise ApiException(status=0, reason=msg) - - if _preload_content: - r = RESTResponse(r, r.data) - - # log response body - logger.debug("response body: %s", r.data) - - if not 200 <= r.status <= 299: - if r.status == 400: - raise ValidationException(http_resp=r) - - if r.status == 401: - raise UnauthorizedException(http_resp=r) - - if r.status == 403: - raise ForbiddenException(http_resp=r) - - if r.status == 404: - raise NotFoundException(http_resp=r) - - if r.status == 429: - raise RateLimitExceededError(http_resp=r) - - if 500 <= r.status <= 599: - raise ServiceException(http_resp=r) - - raise ApiException(http_resp=r) + raise ApiException(status=0, reason=msg) + else: + # For GET, HEAD, etc., we can pass query_params as fields if needed + # but we've already appended them to the URL above + pass - return r + return args - def GET( - self, - url, - headers=None, - query_params=None, - _preload_content=True, - _request_timeout=None, - ): - return self.request( - "GET", - url, - headers=headers, - _preload_content=_preload_content, - _request_timeout=_request_timeout, - query_params=query_params, - ) + def handle_response_exception( + self, response: RESTResponse | urllib3.HTTPResponse + ) -> None: + """ + Raises exceptions if response status indicates an error. - def HEAD( + :param response: The response to check (could be RESTResponse or raw urllib3.HTTPResponse). + """ + if 200 <= response.status <= 299: + return + + match response.status: + case 400: + raise ValidationException(http_resp=response) + case 401: + raise UnauthorizedException(http_resp=response) + case 403: + raise ForbiddenException(http_resp=response) + case 404: + raise NotFoundException(http_resp=response) + case 429: + raise RateLimitExceededError(http_resp=response) + case _ if 500 <= response.status <= 599: + raise ServiceException(http_resp=response) + case _: + raise ApiException(http_resp=response) + + def _accumulate_json_lines( + self, leftover: bytes, data: bytes, buffer: bytearray + ) -> Tuple[bytes, List[Any]]: + """ + Processes a chunk of data plus any leftover bytes from a previous iteration. + Splits on newlines, decodes valid JSON lines, and returns updated leftover bytes + plus a list of decoded JSON objects. + + :param leftover: Any leftover bytes from previous chunks. + :param data: The new chunk of data. + :param buffer: The main bytearray buffer for all data in this request. + :return: A tuple of (updated leftover bytes, list of decoded objects). + """ + objects: List[Any] = [] + leftover += data + lines = leftover.split(b"\n") + leftover = lines.pop() + buffer.extend(data) + + for line in lines: + line_str = line.decode("utf-8") + try: + decoded = json.loads(line_str) + objects.append(decoded) + except json.JSONDecodeError as e: + logger.warning("Skipping invalid JSON segment: %s", e) + + return leftover, objects + + def stream( self, - url, - headers=None, - query_params=None, - _preload_content=True, - _request_timeout=None, + method: str, + url: str, + query_params: Optional[dict] = None, + headers: Optional[dict] = None, + body: Optional[Any] = None, + post_params: Optional[dict] = None, + _request_timeout: Optional[float | tuple] = None, ): - return self.request( - "HEAD", + """ + Streams JSON objects from a specified endpoint, reassembling partial chunks + and yielding one decoded object at a time. + + :param method: The HTTP method (GET, POST, etc.). + :param url: The endpoint URL. + :param query_params: Query parameters to be appended to the URL. + :param headers: Optional headers to include in the request. + :param body: Optional body for the request. + :param post_params: Optional form/multipart parameters. + :param _request_timeout: An optional request timeout in seconds or (connect, read) tuple. + :yields: Parsed JSON objects as Python data structures. + """ + args = self.build_request( + method, url, - headers=headers, - _preload_content=_preload_content, - _request_timeout=_request_timeout, query_params=query_params, - ) - - def OPTIONS( - self, - url, - headers=None, - query_params=None, - post_params=None, - body=None, - _preload_content=True, - _request_timeout=None, - ): - return self.request( - "OPTIONS", - url, headers=headers, - query_params=query_params, + body=body, post_params=post_params, - _preload_content=_preload_content, + _preload_content=False, _request_timeout=_request_timeout, - body=body, ) - def DELETE( - self, - url, - headers=None, - query_params=None, - body=None, - _preload_content=True, - _request_timeout=None, - ): - return self.request( - "DELETE", - url, - headers=headers, - query_params=query_params, - _preload_content=_preload_content, - _request_timeout=_request_timeout, - body=body, - ) + response = self.pool_manager.request(**args) + leftover = b"" + buffer = bytearray() - def POST( - self, - url, - headers=None, - query_params=None, - post_params=None, - body=None, - _preload_content=True, - _request_timeout=None, - ): - return self.request( - "POST", - url, - headers=headers, - query_params=query_params, - post_params=post_params, - _preload_content=_preload_content, - _request_timeout=_request_timeout, - body=body, - ) + try: + for chunk in response.stream(1024): + leftover, decoded_objects = self._accumulate_json_lines( + leftover, chunk, buffer + ) + for obj in decoded_objects: + yield obj - def PUT( - self, - url, - headers=None, - query_params=None, - post_params=None, - body=None, - _preload_content=True, - _request_timeout=None, - ): - return self.request( - "PUT", - url, - headers=headers, - query_params=query_params, - post_params=post_params, - _preload_content=_preload_content, - _request_timeout=_request_timeout, - body=body, - ) + except Exception as e: + logger.exception("Stream error: %s", e) + + if response is not None: + if leftover: + try: + final_str = leftover.decode("utf-8") + final_obj = json.loads(final_str) + buffer.extend(leftover) + yield final_obj + except json.JSONDecodeError: + logger.debug("Incomplete leftover data at end of stream.") + + self.handle_response_exception(response) + response.release_conn() - def PATCH( + self.close() + + def request( self, - url, - headers=None, - query_params=None, - post_params=None, - body=None, - _preload_content=True, - _request_timeout=None, - ): - return self.request( - "PATCH", + method: str, + url: str, + query_params: Optional[dict] = None, + headers: Optional[dict] = None, + body: Optional[Any] = None, + post_params: Optional[dict] = None, + _preload_content: bool = True, + _request_timeout: Optional[float | tuple] = None, + ) -> RESTResponse | urllib3.HTTPResponse: + """ + Executes a request and returns the response object. + + :param method: The HTTP method. + :param url: The endpoint URL. + :param query_params: Query parameters to be appended to the URL. + :param headers: Optional request headers. + :param body: A request body for JSON or other content types. + :param post_params: Form/multipart parameters for the request. + :param _preload_content: If True, the response body is read immediately + and wrapped in a RESTResponse. Otherwise, + an un-consumed urllib3.HTTPResponse is returned. + :param _request_timeout: Timeout in seconds or a (connect, read) tuple. + :return: A RESTResponse if _preload_content=True, otherwise a raw HTTPResponse. + """ + args = self.build_request( + method, url, - headers=headers, query_params=query_params, + headers=headers, + body=body, post_params=post_params, _preload_content=_preload_content, _request_timeout=_request_timeout, - body=body, ) + + resp = self.pool_manager.request(**args) + + if _preload_content: + resp = RESTResponse(resp, resp.data) + logger.debug("response body: %s", resp.data) + + self.handle_response_exception(resp) + self.close() + + return resp diff --git a/test-requirements.txt b/test-requirements.txt index 0fda1de..aae4461 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -8,3 +8,4 @@ griffe >= 0.41.2, < 2 isort==5.13.2 pytest-cov >= 5, < 7 pyupgrade==3.19.1 +pytest-asyncio >= 0.25, < 1 diff --git a/test/api/open_fga_api_test.py b/test/api/open_fga_api_test.py index 83ae7b3..10a766d 100644 --- a/test/api/open_fga_api_test.py +++ b/test/api/open_fga_api_test.py @@ -222,8 +222,9 @@ async def test_delete_store(self, mock_request): "DELETE", "http://api.fga.example/stores/01H0H015178Y2V4CX10C2KGHF4", headers=ANY, - query_params=[], body=None, + query_params=[], + post_params=[], _preload_content=ANY, _request_timeout=None, ) @@ -302,7 +303,9 @@ async def test_get_store(self, mock_request): "GET", "http://api.fga.example/stores/01H0H015178Y2V4CX10C2KGHF4", headers=ANY, + body=None, query_params=[], + post_params=[], _preload_content=ANY, _request_timeout=None, ) @@ -415,10 +418,12 @@ async def test_list_stores(self, mock_request): "GET", "http://api.fga.example/stores", headers=ANY, + body=None, query_params=[ ("page_size", 1), ("continuation_token", "continuation_token_example"), ], + post_params=[], _preload_content=ANY, _request_timeout=None, ) @@ -656,7 +661,9 @@ async def test_read_assertions(self, mock_request): "GET", "http://api.fga.example/stores/01H0H015178Y2V4CX10C2KGHF4/assertions/01G5JAVJ41T49E9TT3SKVS7X1J", headers=ANY, + body=None, query_params=[], + post_params=[], _preload_content=ANY, _request_timeout=None, ) @@ -746,7 +753,9 @@ async def test_read_authorization_model(self, mock_request): "GET", "http://api.fga.example/stores/01H0H015178Y2V4CX10C2KGHF4/authorization-models/01G5JAVJ41T49E9TT3SKVS7X1J", headers=ANY, + body=None, query_params=[], + post_params=[], _preload_content=ANY, _request_timeout=None, ) @@ -807,12 +816,14 @@ async def test_read_changes(self, mock_request): "GET", "http://api.fga.example/stores/01H0H015178Y2V4CX10C2KGHF4/changes", headers=ANY, + body=None, query_params=[ ("type", "document"), ("page_size", 1), ("continuation_token", "abcdefg"), ("start_time", "2022-01-01T00:00:00+00:00"), ], + post_params=[], _preload_content=ANY, _request_timeout=None, ) diff --git a/test/client/client_test.py b/test/client/client_test.py index bc13570..d4c2e30 100644 --- a/test/client/client_test.py +++ b/test/client/client_test.py @@ -178,10 +178,12 @@ async def test_list_stores(self, mock_request): "GET", "http://api.fga.example/stores", headers=ANY, + body=None, query_params=[ ("page_size", 1), ("continuation_token", "continuation_token_example"), ], + post_params=[], _preload_content=ANY, _request_timeout=None, ) @@ -245,7 +247,9 @@ async def test_get_store(self, mock_request): "GET", "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X", headers=ANY, + body=None, query_params=[], + post_params=[], _preload_content=ANY, _request_timeout=None, ) @@ -266,8 +270,9 @@ async def test_delete_store(self, mock_request): "DELETE", "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X", headers=ANY, - query_params=[], body=None, + query_params=[], + post_params=[], _preload_content=ANY, _request_timeout=None, ) @@ -359,7 +364,9 @@ async def test_read_authorization_models(self, mock_request): "GET", "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/authorization-models", headers=ANY, + body=None, query_params=[], + post_params=[], _preload_content=ANY, _request_timeout=None, ) @@ -526,7 +533,9 @@ async def test_read_authorization_model(self, mock_request): "GET", "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/authorization-models/01G5JAVJ41T49E9TT3SKVS7X1J", headers=ANY, + body=None, query_params=[], + post_params=[], _preload_content=ANY, _request_timeout=None, ) @@ -613,7 +622,9 @@ async def test_read_latest_authorization_model(self, mock_request): "GET", "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/authorization-models", headers=ANY, + body=None, query_params=[("page_size", 1)], + post_params=[], _preload_content=ANY, _request_timeout=None, ) @@ -643,7 +654,9 @@ async def test_read_latest_authorization_model_with_no_models(self, mock_request "GET", "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/authorization-models", headers=ANY, + body=None, query_params=[("page_size", 1)], + post_params=[], _preload_content=ANY, _request_timeout=None, ) @@ -701,12 +714,14 @@ async def test_read_changes(self, mock_request): "GET", "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/changes", headers=ANY, + body=None, query_params=[ ("type", "document"), ("page_size", 1), ("continuation_token", "abcdefg"), ("start_time", "2022-01-01T00:00:00+00:00"), ], + post_params=[], _preload_content=ANY, _request_timeout=None, ) @@ -885,9 +900,9 @@ async def test_read_empty_body(self, mock_request): "POST", "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/read", headers=ANY, + body={}, query_params=[], post_params=[], - body={}, _preload_content=ANY, _request_timeout=None, ) @@ -2977,7 +2992,9 @@ async def test_read_assertions(self, mock_request): "GET", "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/assertions/01G5JAVJ41T49E9TT3SKVS7X1J", headers=ANY, + body=None, query_params=[], + post_params=[], _preload_content=ANY, _request_timeout=None, ) diff --git a/test/credentials_test.py b/test/credentials_test.py index 003de98..9f3915d 100644 --- a/test/credentials_test.py +++ b/test/credentials_test.py @@ -224,7 +224,3 @@ def test_invalid_issuer_with_port(self): self.configuration.api_issuer = "https://issuer.fga.example:8080 " result = self.credentials._parse_issuer(self.configuration.api_issuer) self.assertEqual(result, "https://issuer.fga.example:8080/oauth/token") - - -if __name__ == "__main__": - unittest.main() diff --git a/test/oauth2_test.py b/test/oauth2_test.py index 77e902a..7f50ce3 100644 --- a/test/oauth2_test.py +++ b/test/oauth2_test.py @@ -88,8 +88,8 @@ async def test_get_authentication_obtain_client_credentials(self, mock_request): } ) mock_request.assert_called_once_with( - "POST", - "https://issuer.fga.example/oauth/token", + method="POST", + url="https://issuer.fga.example/oauth/token", headers=expected_header, query_params=None, body=None, diff --git a/test/rest_test.py b/test/rest_test.py new file mode 100644 index 0000000..3257cb7 --- /dev/null +++ b/test/rest_test.py @@ -0,0 +1,411 @@ +""" + Python SDK for OpenFGA + + API version: 1.x + Website: https://openfga.dev + Documentation: https://openfga.dev/docs + Support: https://openfga.dev/community + License: [Apache-2.0](https://github.com/openfga/python-sdk/blob/main/LICENSE) + + NOTE: This file was auto generated by OpenAPI Generator (https://openapi-generator.tech). DO NOT EDIT. +""" + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from openfga_sdk.exceptions import ( + ApiException, + ForbiddenException, + NotFoundException, + RateLimitExceededError, + ServiceException, + UnauthorizedException, + ValidationException, +) +from openfga_sdk.rest import RESTClientObject, RESTResponse + + +@pytest.mark.asyncio +async def test_restresponse_init(): + mock_resp = MagicMock() + mock_resp.status = 200 + mock_resp.reason = "OK" + resp_data = b'{"test":"data"}' + rest_resp = RESTResponse(mock_resp, resp_data) + assert rest_resp.status == 200 + assert rest_resp.reason == "OK" + assert rest_resp.data == resp_data + assert rest_resp.aiohttp_response == mock_resp + + +def test_restresponse_getheaders(): + mock_resp = MagicMock() + mock_resp.headers = {"Content-Type": "application/json", "X-Testing": "true"} + rest_resp = RESTResponse(mock_resp, b"") + headers = rest_resp.getheaders() + assert headers["Content-Type"] == "application/json" + assert headers["X-Testing"] == "true" + + +def test_restresponse_getheader(): + mock_resp = MagicMock() + mock_resp.headers = {"Content-Type": "application/json"} + rest_resp = RESTResponse(mock_resp, b"") + val = rest_resp.getheader("Content-Type") + missing = rest_resp.getheader("X-Not-Here", default="fallback") + assert val == "application/json" + assert missing == "fallback" + + +@pytest.mark.asyncio +async def test_build_request_json_body(): + mock_config = MagicMock() + mock_config.ssl_ca_cert = None + mock_config.cert_file = None + mock_config.key_file = None + mock_config.verify_ssl = True + mock_config.connection_pool_maxsize = 4 + mock_config.proxy = None + mock_config.proxy_headers = None + mock_config.timeout_millisec = 5000 + + client = RESTClientObject(configuration=mock_config) + req_args = await client.build_request( + method="POST", + url="http://example.com/test", + body={"foo": "bar"}, + headers={"Content-Type": "application/json"}, + ) + assert req_args["method"] == "POST" + assert req_args["url"] == "http://example.com/test" + assert req_args["headers"]["Content-Type"] == "application/json" + assert json.loads(req_args["data"]) == {"foo": "bar"} + + +@pytest.mark.asyncio +async def test_build_request_form_data(): + mock_config = MagicMock() + mock_config.ssl_ca_cert = None + mock_config.cert_file = None + mock_config.key_file = None + mock_config.verify_ssl = True + mock_config.connection_pool_maxsize = 4 + mock_config.proxy = None + mock_config.proxy_headers = None + mock_config.timeout_millisec = 5000 + + client = RESTClientObject(configuration=mock_config) + req_args = await client.build_request( + method="POST", + url="http://example.com/upload", + post_params=[("file", ("filename.txt", b"contents", "text/plain"))], + headers={"Content-Type": "multipart/form-data"}, + ) + assert req_args["method"] == "POST" + assert req_args["url"] == "http://example.com/upload" + assert "Content-Type" not in req_args["headers"] + assert "data" in req_args + + +@pytest.mark.asyncio +async def test_build_request_timeout(): + mock_config = MagicMock() + mock_config.ssl_ca_cert = None + mock_config.cert_file = None + mock_config.key_file = None + mock_config.verify_ssl = True + mock_config.connection_pool_maxsize = 4 + mock_config.proxy = None + mock_config.proxy_headers = None + mock_config.timeout_millisec = 5000 + + client = RESTClientObject(configuration=mock_config) + req_args = await client.build_request( + method="GET", + url="http://example.com", + _request_timeout=10.0, + ) + assert req_args["timeout"] == 10.0 + + +@pytest.mark.asyncio +async def test_handle_response_exception_success(): + mock_config = MagicMock() + mock_config.ssl_ca_cert = None + mock_config.cert_file = None + mock_config.key_file = None + mock_config.verify_ssl = True + mock_config.connection_pool_maxsize = 4 + mock_config.proxy = None + mock_config.proxy_headers = None + mock_config.timeout_millisec = 5000 + + client = RESTClientObject(configuration=mock_config) + mock_response = MagicMock() + mock_response.status = 200 + await client.handle_response_exception(mock_response) + + +@pytest.mark.parametrize( + "status, exc", + [ + (400, ValidationException), + (401, UnauthorizedException), + (403, ForbiddenException), + (404, NotFoundException), + (429, RateLimitExceededError), + (500, ServiceException), + (418, ApiException), + ], +) +@pytest.mark.asyncio +async def test_handle_response_exception_error(status, exc): + mock_config = MagicMock() + mock_config.ssl_ca_cert = None + mock_config.cert_file = None + mock_config.key_file = None + mock_config.verify_ssl = True + mock_config.connection_pool_maxsize = 4 + mock_config.proxy = None + mock_config.proxy_headers = None + mock_config.timeout_millisec = 5000 + + client = RESTClientObject(configuration=mock_config) + mock_response = MagicMock() + mock_response.status = status + + with pytest.raises(exc): + await client.handle_response_exception(mock_response) + + +@pytest.mark.asyncio +async def test_close(): + mock_config = MagicMock() + mock_config.ssl_ca_cert = None + mock_config.cert_file = None + mock_config.key_file = None + mock_config.verify_ssl = True + mock_config.connection_pool_maxsize = 4 + mock_config.proxy = None + mock_config.proxy_headers = None + mock_config.timeout_millisec = 5000 + + client = RESTClientObject(configuration=mock_config) + + mock_session = MagicMock() + mock_session.close = AsyncMock() + client.pool_manager = mock_session + + await client.close() + + mock_session.close.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_request_preload_content(): + # Mock config + mock_config = MagicMock() + mock_config.ssl_ca_cert = None + mock_config.cert_file = None + mock_config.key_file = None + mock_config.verify_ssl = True + mock_config.connection_pool_maxsize = 4 + mock_config.proxy = None + mock_config.proxy_headers = None + mock_config.timeout_millisec = 5000 + + client = RESTClientObject(configuration=mock_config) + + mock_session = MagicMock() + client.pool_manager = mock_session + + mock_raw_response = MagicMock() + mock_raw_response.status = 200 + mock_raw_response.reason = "OK" + mock_raw_response.read = AsyncMock(return_value=b'{"some":"data"}') + mock_session.request = AsyncMock(return_value=mock_raw_response) + + resp = await client.request( + method="GET", url="http://example.com", _preload_content=True + ) + + mock_session.request.assert_awaited_once() + mock_raw_response.read.assert_awaited_once() + + assert resp.status == 200 + assert resp.reason == "OK" + assert resp.data == b'{"some":"data"}' + + +@pytest.mark.asyncio +async def test_request_no_preload_content(): + mock_config = MagicMock() + mock_config.ssl_ca_cert = None + mock_config.cert_file = None + mock_config.key_file = None + mock_config.verify_ssl = True + mock_config.connection_pool_maxsize = 4 + mock_config.proxy = None + mock_config.proxy_headers = None + mock_config.timeout_millisec = 5000 + + client = RESTClientObject(configuration=mock_config) + + mock_session = MagicMock() + client.pool_manager = mock_session + + mock_raw_response = MagicMock() + mock_raw_response.status = 200 + mock_raw_response.reason = "OK" + mock_raw_response.read = AsyncMock(return_value=b"unused") + mock_session.request = AsyncMock(return_value=mock_raw_response) + + resp = await client.request( + method="GET", url="http://example.com", _preload_content=False + ) + + mock_session.request.assert_awaited_once() + + assert resp == mock_raw_response + assert resp.status == 200 + assert resp.reason == "OK" + + +@pytest.mark.asyncio +async def test_stream_happy_path(): + mock_config = MagicMock() + mock_config.ssl_ca_cert = None + mock_config.cert_file = None + mock_config.key_file = None + mock_config.verify_ssl = True + mock_config.connection_pool_maxsize = 4 + mock_config.proxy = None + mock_config.proxy_headers = None + mock_config.timeout_millisec = 5000 + + client = RESTClientObject(configuration=mock_config) + mock_session = MagicMock() + client.pool_manager = mock_session + + class FakeContent: + async def iter_chunks(self): + yield (b'{"foo":"bar"}\n{"hello":"world"}', None) + + mock_response = MagicMock() + mock_response.status = 200 + mock_response.reason = "OK" + mock_response.data = None + mock_response.content = FakeContent() + + mock_context_manager = AsyncMock() + mock_context_manager.__aenter__.return_value = mock_response + mock_context_manager.__aexit__.return_value = None + + mock_session.request.return_value = mock_context_manager + + client.handle_response_exception = AsyncMock() + client.close = AsyncMock() + + results = [] + async for item in client.stream("GET", "http://example.com"): + results.append(item) + + assert results == [{"foo": "bar"}, {"hello": "world"}] + + client.handle_response_exception.assert_awaited_once() + mock_response.release.assert_called_once() + client.close.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_stream_exception_in_chunks(): + mock_config = MagicMock() + mock_config.ssl_ca_cert = None + mock_config.cert_file = None + mock_config.key_file = None + mock_config.verify_ssl = True + mock_config.connection_pool_maxsize = 4 + mock_config.proxy = None + mock_config.proxy_headers = None + mock_config.timeout_millisec = 5000 + + client = RESTClientObject(configuration=mock_config) + mock_session = MagicMock() + client.pool_manager = mock_session + + class FakeContent: + async def iter_chunks(self): + raise ValueError("Boom!") + + mock_response = MagicMock() + mock_response.status = 200 + mock_response.reason = "OK" + mock_response.data = None + mock_response.content = FakeContent() + + mock_context_manager = AsyncMock() + mock_context_manager.__aenter__.return_value = mock_response + mock_context_manager.__aexit__.return_value = None + + mock_session.request.return_value = mock_context_manager + + client.handle_response_exception = AsyncMock() + client.close = AsyncMock() + + results = [] + async for item in client.stream("GET", "http://example.com"): + results.append(item) + + assert results == [] + client.handle_response_exception.assert_awaited_once() + mock_response.release.assert_called_once() + client.close.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_stream_partial_chunks(): + mock_config = MagicMock() + mock_config.ssl_ca_cert = None + mock_config.cert_file = None + mock_config.key_file = None + mock_config.verify_ssl = True + mock_config.connection_pool_maxsize = 4 + mock_config.proxy = None + mock_config.proxy_headers = None + mock_config.timeout_millisec = 5000 + + client = RESTClientObject(configuration=mock_config) + mock_session = MagicMock() + client.pool_manager = mock_session + + class FakeContent: + async def iter_chunks(self): + yield (b'{"foo":"b', None) + yield (b'ar"}\n{"hello":"world"}', None) + + mock_response = MagicMock() + mock_response.status = 200 + mock_response.reason = "OK" + mock_response.data = None + mock_response.content = FakeContent() + + mock_context_manager = AsyncMock() + mock_context_manager.__aenter__.return_value = mock_response + mock_context_manager.__aexit__.return_value = None + + mock_session.request.return_value = mock_context_manager + + client.handle_response_exception = AsyncMock() + client.close = AsyncMock() + + results = [] + async for item in client.stream("GET", "http://example.com"): + results.append(item) + + assert results == [{"foo": "bar"}, {"hello": "world"}] + + client.handle_response_exception.assert_awaited_once() + mock_response.release.assert_called_once() + client.close.assert_awaited_once() diff --git a/test/sync/client/client_test.py b/test/sync/client/client_test.py index f7eabe0..3435ded 100644 --- a/test/sync/client/client_test.py +++ b/test/sync/client/client_test.py @@ -177,10 +177,12 @@ def test_list_stores(self, mock_request): "GET", "http://api.fga.example/stores", headers=ANY, + body=None, query_params=[ ("page_size", 1), ("continuation_token", "continuation_token_example"), ], + post_params=[], _preload_content=ANY, _request_timeout=None, ) @@ -244,7 +246,9 @@ def test_get_store(self, mock_request): "GET", "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X", headers=ANY, + body=None, query_params=[], + post_params=[], _preload_content=ANY, _request_timeout=None, ) @@ -265,8 +269,9 @@ def test_delete_store(self, mock_request): "DELETE", "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X", headers=ANY, - query_params=[], body=None, + query_params=[], + post_params=[], _preload_content=ANY, _request_timeout=None, ) @@ -358,7 +363,9 @@ def test_read_authorization_models(self, mock_request): "GET", "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/authorization-models", headers=ANY, + body=None, query_params=[], + post_params=[], _preload_content=ANY, _request_timeout=None, ) @@ -525,7 +532,9 @@ def test_read_authorization_model(self, mock_request): "GET", "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/authorization-models/01G5JAVJ41T49E9TT3SKVS7X1J", headers=ANY, + body=None, query_params=[], + post_params=[], _preload_content=ANY, _request_timeout=None, ) @@ -612,7 +621,9 @@ def test_read_latest_authorization_model(self, mock_request): "GET", "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/authorization-models", headers=ANY, + body=None, query_params=[("page_size", 1)], + post_params=[], _preload_content=ANY, _request_timeout=None, ) @@ -642,7 +653,9 @@ def test_read_latest_authorization_model_with_no_models(self, mock_request): "GET", "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/authorization-models", headers=ANY, + body=None, query_params=[("page_size", 1)], + post_params=[], _preload_content=ANY, _request_timeout=None, ) @@ -700,12 +713,14 @@ def test_read_changes(self, mock_request): "GET", "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/changes", headers=ANY, + body=None, query_params=[ ("type", "document"), ("page_size", 1), ("continuation_token", "abcdefg"), ("start_time", "2022-01-01T00:00:00+00:00"), ], + post_params=[], _preload_content=ANY, _request_timeout=None, ) @@ -2979,7 +2994,9 @@ def test_read_assertions(self, mock_request): "GET", "http://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/assertions/01G5JAVJ41T49E9TT3SKVS7X1J", headers=ANY, + body=None, query_params=[], + post_params=[], _preload_content=ANY, _request_timeout=None, ) diff --git a/test/sync/oauth2_test.py b/test/sync/oauth2_test.py index f84ae26..f5d81f0 100644 --- a/test/sync/oauth2_test.py +++ b/test/sync/oauth2_test.py @@ -88,8 +88,8 @@ def test_get_authentication_obtain_client_credentials(self, mock_request): } ) mock_request.assert_called_once_with( - "POST", - "https://issuer.fga.example/oauth/token", + method="POST", + url="https://issuer.fga.example/oauth/token", headers=expected_header, query_params=None, body=None, diff --git a/test/sync/open_fga_api_test.py b/test/sync/open_fga_api_test.py index bd89492..57ba736 100644 --- a/test/sync/open_fga_api_test.py +++ b/test/sync/open_fga_api_test.py @@ -224,6 +224,7 @@ async def test_delete_store(self, mock_request): "http://api.fga.example/stores/01H0H015178Y2V4CX10C2KGHF4", headers=ANY, query_params=[], + post_params=[], body=None, _preload_content=ANY, _request_timeout=None, @@ -303,7 +304,9 @@ async def test_get_store(self, mock_request): "GET", "http://api.fga.example/stores/01H0H015178Y2V4CX10C2KGHF4", headers=ANY, + body=None, query_params=[], + post_params=[], _preload_content=ANY, _request_timeout=None, ) @@ -416,10 +419,12 @@ async def test_list_stores(self, mock_request): "GET", "http://api.fga.example/stores", headers=ANY, + body=None, query_params=[ ("page_size", 1), ("continuation_token", "continuation_token_example"), ], + post_params=[], _preload_content=ANY, _request_timeout=None, ) @@ -660,7 +665,9 @@ async def test_read_assertions(self, mock_request): "GET", "http://api.fga.example/stores/01H0H015178Y2V4CX10C2KGHF4/assertions/01G5JAVJ41T49E9TT3SKVS7X1J", headers=ANY, + body=None, query_params=[], + post_params=[], _preload_content=ANY, _request_timeout=None, ) @@ -750,7 +757,9 @@ async def test_read_authorization_model(self, mock_request): "GET", "http://api.fga.example/stores/01H0H015178Y2V4CX10C2KGHF4/authorization-models/01G5JAVJ41T49E9TT3SKVS7X1J", headers=ANY, + body=None, query_params=[], + post_params=[], _preload_content=ANY, _request_timeout=None, ) @@ -811,12 +820,14 @@ async def test_read_changes(self, mock_request): "GET", "http://api.fga.example/stores/01H0H015178Y2V4CX10C2KGHF4/changes", headers=ANY, + body=None, query_params=[ ("type", "document"), ("page_size", 1), ("continuation_token", "abcdefg"), ("start_time", "2022-01-01T00:00:00+00:00"), ], + post_params=[], _preload_content=ANY, _request_timeout=None, ) diff --git a/test/sync/rest_test.py b/test/sync/rest_test.py new file mode 100644 index 0000000..b3781f4 --- /dev/null +++ b/test/sync/rest_test.py @@ -0,0 +1,535 @@ +""" + Python SDK for OpenFGA + + API version: 1.x + Website: https://openfga.dev + Documentation: https://openfga.dev/docs + Support: https://openfga.dev/community + License: [Apache-2.0](https://github.com/openfga/python-sdk/blob/main/LICENSE) + + NOTE: This file was auto generated by OpenAPI Generator (https://openapi-generator.tech). DO NOT EDIT. +""" + +import json +from unittest.mock import MagicMock + +import pytest + +from openfga_sdk.exceptions import ( + ApiException, + ForbiddenException, + NotFoundException, + RateLimitExceededError, + ServiceException, + UnauthorizedException, + ValidationException, +) +from openfga_sdk.sync.rest import RESTClientObject, RESTResponse + + +def test_restresponse_init(): + mock_resp = MagicMock() + mock_resp.status = 200 + mock_resp.reason = "OK" + resp_data = b'{"test":"data"}' + + rest_resp = RESTResponse(mock_resp, resp_data) + assert rest_resp.status == 200 + assert rest_resp.reason == "OK" + assert rest_resp.data == resp_data + assert rest_resp.urllib3_response == mock_resp + + +def test_restresponse_getheaders(): + mock_resp = MagicMock() + mock_resp.headers = {"Content-Type": "application/json", "X-Testing": "true"} + + rest_resp = RESTResponse(mock_resp, b"") + headers = rest_resp.getheaders() + + assert headers["Content-Type"] == "application/json" + assert headers["X-Testing"] == "true" + + +def test_restresponse_getheader(): + mock_resp = MagicMock() + mock_resp.headers = {"Content-Type": "application/json"} + + rest_resp = RESTResponse(mock_resp, b"") + val = rest_resp.getheader("Content-Type") + missing = rest_resp.getheader("X-Not-Here", default="fallback") + + assert val == "application/json" + assert missing == "fallback" + + +def test_build_request_json_body(): + mock_config = MagicMock( + spec=[ + "verify_ssl", + "ssl_ca_cert", + "cert_file", + "key_file", + "assert_hostname", + "retries", + "socket_options", + "connection_pool_maxsize", + "timeout_millisec", + "proxy", + "proxy_headers", + ] + ) + mock_config.ssl_ca_cert = None + mock_config.cert_file = None + mock_config.key_file = None + mock_config.verify_ssl = True + mock_config.connection_pool_maxsize = 4 + mock_config.timeout_millisec = 5000 + mock_config.proxy = None + mock_config.proxy_headers = None + + client = RESTClientObject(configuration=mock_config) + req_args = client.build_request( + method="POST", + url="http://example.com/test", + body={"foo": "bar"}, + headers={"Content-Type": "application/json"}, + ) + + assert req_args["method"] == "POST" + assert req_args["url"] == "http://example.com/test" + assert req_args["headers"]["Content-Type"] == "application/json" + assert json.loads(req_args["body"]) == {"foo": "bar"} + + +def test_build_request_multipart(): + mock_config = MagicMock( + spec=[ + "verify_ssl", + "ssl_ca_cert", + "cert_file", + "key_file", + "assert_hostname", + "retries", + "socket_options", + "connection_pool_maxsize", + "timeout_millisec", + "proxy", + "proxy_headers", + ] + ) + mock_config.ssl_ca_cert = None + mock_config.cert_file = None + mock_config.key_file = None + mock_config.verify_ssl = True + mock_config.connection_pool_maxsize = 4 + mock_config.timeout_millisec = 5000 + mock_config.proxy = None + mock_config.proxy_headers = None + + client = RESTClientObject(configuration=mock_config) + req_args = client.build_request( + method="POST", + url="http://example.com/upload", + post_params={"file": ("filename.txt", b"contents", "text/plain")}, + headers={"Content-Type": "multipart/form-data"}, + ) + + assert req_args["method"] == "POST" + assert req_args["url"] == "http://example.com/upload" + assert "Content-Type" not in req_args["headers"] + assert req_args["encode_multipart"] is True + assert req_args["fields"] == {"file": ("filename.txt", b"contents", "text/plain")} + + +def test_build_request_timeout(): + mock_config = MagicMock( + spec=[ + "verify_ssl", + "ssl_ca_cert", + "cert_file", + "key_file", + "assert_hostname", + "retries", + "socket_options", + "connection_pool_maxsize", + "timeout_millisec", + "proxy", + "proxy_headers", + ] + ) + mock_config.ssl_ca_cert = None + mock_config.cert_file = None + mock_config.key_file = None + mock_config.verify_ssl = True + mock_config.connection_pool_maxsize = 4 + mock_config.timeout_millisec = 5000 + mock_config.proxy = None + mock_config.proxy_headers = None + + client = RESTClientObject(configuration=mock_config) + req_args = client.build_request( + method="GET", + url="http://example.com", + _request_timeout=10.0, + ) + + # We'll just confirm that the "timeout" object was set to 10.0 + # A deeper check might be verifying urllib3.Timeout, but this suffices. + assert req_args["timeout"].total == 10.0 + + +def test_handle_response_exception_success(): + mock_config = MagicMock( + spec=[ + "verify_ssl", + "ssl_ca_cert", + "cert_file", + "key_file", + "assert_hostname", + "retries", + "socket_options", + "connection_pool_maxsize", + "timeout_millisec", + "proxy", + "proxy_headers", + ] + ) + mock_config.ssl_ca_cert = None + mock_config.cert_file = None + mock_config.key_file = None + mock_config.verify_ssl = True + mock_config.connection_pool_maxsize = 4 + mock_config.timeout_millisec = 5000 + mock_config.proxy = None + mock_config.proxy_headers = None + + client = RESTClientObject(configuration=mock_config) + mock_response = MagicMock() + mock_response.status = 200 + + client.handle_response_exception(mock_response) # no exception + + +@pytest.mark.parametrize( + "status,exc", + [ + (400, ValidationException), + (401, UnauthorizedException), + (403, ForbiddenException), + (404, NotFoundException), + (429, RateLimitExceededError), + (500, ServiceException), + (418, ApiException), + ], +) +def test_handle_response_exception_error(status, exc): + mock_config = MagicMock( + spec=[ + "verify_ssl", + "ssl_ca_cert", + "cert_file", + "key_file", + "assert_hostname", + "retries", + "socket_options", + "connection_pool_maxsize", + "timeout_millisec", + "proxy", + "proxy_headers", + ] + ) + mock_config.ssl_ca_cert = None + mock_config.cert_file = None + mock_config.key_file = None + mock_config.verify_ssl = True + mock_config.connection_pool_maxsize = 4 + mock_config.timeout_millisec = 5000 + mock_config.proxy = None + mock_config.proxy_headers = None + + client = RESTClientObject(configuration=mock_config) + mock_response = MagicMock() + mock_response.status = status + + with pytest.raises(exc): + client.handle_response_exception(mock_response) + + +def test_close(): + mock_config = MagicMock( + spec=[ + "verify_ssl", + "ssl_ca_cert", + "cert_file", + "key_file", + "assert_hostname", + "retries", + "socket_options", + "connection_pool_maxsize", + "timeout_millisec", + "proxy", + "proxy_headers", + ] + ) + mock_config.ssl_ca_cert = None + mock_config.cert_file = None + mock_config.key_file = None + mock_config.verify_ssl = True + mock_config.connection_pool_maxsize = 4 + mock_config.timeout_millisec = 5000 + mock_config.proxy = None + mock_config.proxy_headers = None + + client = RESTClientObject(configuration=mock_config) + mock_pool_manager = MagicMock() + client.pool_manager = mock_pool_manager + + client.close() + mock_pool_manager.clear.assert_called_once() + + +def test_request_preload_content(): + mock_config = MagicMock( + spec=[ + "verify_ssl", + "ssl_ca_cert", + "cert_file", + "key_file", + "assert_hostname", + "retries", + "socket_options", + "connection_pool_maxsize", + "timeout_millisec", + "proxy", + "proxy_headers", + ] + ) + mock_config.ssl_ca_cert = None + mock_config.cert_file = None + mock_config.key_file = None + mock_config.verify_ssl = True + mock_config.connection_pool_maxsize = 4 + mock_config.timeout_millisec = 5000 + mock_config.proxy = None + mock_config.proxy_headers = None + + client = RESTClientObject(configuration=mock_config) + mock_pool_manager = MagicMock() + client.pool_manager = mock_pool_manager + + mock_raw_response = MagicMock() + mock_raw_response.status = 200 + mock_raw_response.reason = "OK" + mock_raw_response.data = b'{"some":"data"}' + + mock_pool_manager.request.return_value = mock_raw_response + + resp = client.request(method="GET", url="http://example.com", _preload_content=True) + + mock_pool_manager.request.assert_called_once() + assert isinstance(resp, RESTResponse) + assert resp.status == 200 + assert resp.data == b'{"some":"data"}' + mock_pool_manager.clear.assert_called_once() + + +def test_request_no_preload_content(): + mock_config = MagicMock( + spec=[ + "verify_ssl", + "ssl_ca_cert", + "cert_file", + "key_file", + "assert_hostname", + "retries", + "socket_options", + "connection_pool_maxsize", + "timeout_millisec", + "proxy", + "proxy_headers", + ] + ) + mock_config.ssl_ca_cert = None + mock_config.cert_file = None + mock_config.key_file = None + mock_config.verify_ssl = True + mock_config.connection_pool_maxsize = 4 + mock_config.timeout_millisec = 5000 + mock_config.proxy = None + mock_config.proxy_headers = None + + client = RESTClientObject(configuration=mock_config) + mock_pool_manager = MagicMock() + client.pool_manager = mock_pool_manager + + mock_raw_response = MagicMock() + mock_raw_response.status = 200 + mock_raw_response.reason = "OK" + mock_raw_response.data = b"unused" + + mock_pool_manager.request.return_value = mock_raw_response + + resp = client.request( + method="GET", url="http://example.com", _preload_content=False + ) + + mock_pool_manager.request.assert_called_once() + # We expect the raw HTTPResponse + assert resp == mock_raw_response + assert resp.status == 200 + mock_pool_manager.clear.assert_called_once() + + +def test_stream_happy_path(): + mock_config = MagicMock( + spec=[ + "verify_ssl", + "ssl_ca_cert", + "cert_file", + "key_file", + "assert_hostname", + "retries", + "socket_options", + "connection_pool_maxsize", + "timeout_millisec", + "proxy", + "proxy_headers", + ] + ) + mock_config.ssl_ca_cert = None + mock_config.cert_file = None + mock_config.key_file = None + mock_config.verify_ssl = True + mock_config.connection_pool_maxsize = 4 + mock_config.timeout_millisec = 5000 + mock_config.proxy = None + mock_config.proxy_headers = None + + client = RESTClientObject(configuration=mock_config) + mock_pool_manager = MagicMock() + client.pool_manager = mock_pool_manager + + class FakeHTTPResponse: + def __init__(self): + self.status = 200 + self.reason = "OK" + + def stream(self, chunk_size): + # Single chunk with two JSON lines + yield b'{"foo":"bar"}\n{"hello":"world"}' + + def release_conn(self): + pass + + mock_response = FakeHTTPResponse() + mock_pool_manager.request.return_value = mock_response + + results = list(client.stream("GET", "http://example.com")) + + assert results == [{"foo": "bar"}, {"hello": "world"}] + mock_pool_manager.request.assert_called_once() + mock_pool_manager.clear.assert_called_once() + + +def test_stream_partial_chunks(): + mock_config = MagicMock( + spec=[ + "verify_ssl", + "ssl_ca_cert", + "cert_file", + "key_file", + "assert_hostname", + "retries", + "socket_options", + "connection_pool_maxsize", + "timeout_millisec", + "proxy", + "proxy_headers", + ] + ) + mock_config.ssl_ca_cert = None + mock_config.cert_file = None + mock_config.key_file = None + mock_config.verify_ssl = True + mock_config.connection_pool_maxsize = 4 + mock_config.timeout_millisec = 5000 + mock_config.proxy = None + mock_config.proxy_headers = None + + client = RESTClientObject(configuration=mock_config) + mock_pool_manager = MagicMock() + client.pool_manager = mock_pool_manager + + class FakeHTTPResponse: + def __init__(self): + self.status = 200 + self.reason = "OK" + + def stream(self, chunk_size): + # Two partial chunks that form "foo":"bar" plus a second object + yield b'{"foo":"b' + yield b'ar"}\n{"hello":"world"}' + + def release_conn(self): + pass + + mock_response = FakeHTTPResponse() + mock_pool_manager.request.return_value = mock_response + + results = list(client.stream("GET", "http://example.com")) + + assert results == [{"foo": "bar"}, {"hello": "world"}] + mock_pool_manager.request.assert_called_once() + mock_pool_manager.clear.assert_called_once() + + +def test_stream_exception_in_chunks(): + mock_config = MagicMock( + spec=[ + "verify_ssl", + "ssl_ca_cert", + "cert_file", + "key_file", + "assert_hostname", + "retries", + "socket_options", + "connection_pool_maxsize", + "timeout_millisec", + "proxy", + "proxy_headers", + ] + ) + mock_config.ssl_ca_cert = None + mock_config.cert_file = None + mock_config.key_file = None + mock_config.verify_ssl = True + mock_config.connection_pool_maxsize = 4 + mock_config.timeout_millisec = 5000 + mock_config.proxy = None + mock_config.proxy_headers = None + + client = RESTClientObject(configuration=mock_config) + mock_pool_manager = MagicMock() + client.pool_manager = mock_pool_manager + + class FakeHTTPResponse: + def __init__(self): + self.status = 200 + self.reason = "OK" + + def stream(self, chunk_size): + # Raise an exception while streaming + raise ValueError("Boom!") + + def release_conn(self): + pass + + mock_response = FakeHTTPResponse() + mock_pool_manager.request.return_value = mock_response + + results = list(client.stream("GET", "http://example.com")) + # Exception is logged, we yield nothing + assert results == [] + mock_pool_manager.request.assert_called_once() + mock_pool_manager.clear.assert_called_once()