From 0f7fdea9ed3bfad83cf94a3121ab8dd7c9bd35c1 Mon Sep 17 00:00:00 2001 From: babbarutkarsh <46282290+babbarutkarsh@users.noreply.github.com> Date: Sun, 1 Nov 2026 09:46:32 +0530 Subject: [PATCH] feat(client): accept dictionary request bodies --- docs/dictionary-requests.md | 83 ++++++ openfga_sdk/client/client.py | 51 ++-- openfga_sdk/client/request.py | 30 ++ openfga_sdk/sync/client/client.py | 51 ++-- test/client/dictionary_requests_test.py | 366 ++++++++++++++++++++++++ 5 files changed, 547 insertions(+), 34 deletions(-) create mode 100644 docs/dictionary-requests.md create mode 100644 openfga_sdk/client/request.py create mode 100644 test/client/dictionary_requests_test.py diff --git a/docs/dictionary-requests.md b/docs/dictionary-requests.md new file mode 100644 index 00000000..95f28737 --- /dev/null +++ b/docs/dictionary-requests.md @@ -0,0 +1,83 @@ +# Dictionary request bodies + +Both the asynchronous and synchronous `OpenFgaClient` accept dictionaries in +place of request model instances. Dictionary keys use the same names as the +corresponding Python constructor arguments, rather than the low-level HTTP +request shape. + +For example, these checks are equivalent: + +```python +from openfga_sdk.client.models import ClientCheckRequest + +result = await fga_client.check( + ClientCheckRequest( + user="user:bob", + relation="viewer", + object="document:roadmap", + ) +) + +result = await fga_client.check( + { + "user": "user:bob", + "relation": "viewer", + "object": "document:roadmap", + } +) +``` + +The synchronous client accepts the same bodies without `await`. + +## Nested tuples and batch checks + +Nested contextual tuples, write/delete tuples, and batch check items can also +be dictionaries. Existing model instances can be included in those lists. + +```python +await fga_client.write( + { + "writes": [ + { + "user": "user:bob", + "relation": "viewer", + "object": "document:roadmap", + } + ] + } +) + +result = await fga_client.batch_check( + { + "checks": [ + { + "user": "user:bob", + "relation": "viewer", + "object": "document:roadmap", + "correlation_id": "roadmap-view", + } + ] + } +) +``` + +Methods that take a list (`write_tuples`, `delete_tuples`, `client_batch_check`, +and `write_assertions`) continue to take a list, with dictionaries permitted +as its elements. Options remain a separate argument. + +For dictionary-only requests, the SDK does not insert generated correlation IDs +or replace nested tuples in the supplied dictionaries. Existing model instances +keep their existing behavior: for example, BatchCheck assigns a correlation ID +to a supplied `ClientBatchCheckItem` if it does not have one. + +Request context and condition context retain their dictionary values. The SDK +constructs the outer request and nested client tuples/checks, so missing required +constructor arguments and unexpected keys there raise constructor errors. +Nested generated-model fields, such as a tuple's `condition`, a ListUsers +`object`, and authorization-model definitions, are serialized directly when +provided as dictionaries; their contents are validated by the server. Supply +generated model instances in these fields if you also want their constructor +validation locally. + +This support applies to dictionaries passed as request bodies. It does not +change the accepted constructor arguments of existing request model classes. diff --git a/openfga_sdk/client/client.py b/openfga_sdk/client/client.py index b48a3ec8..65deda3b 100644 --- a/openfga_sdk/client/client.py +++ b/openfga_sdk/client/client.py @@ -1,7 +1,7 @@ import asyncio import uuid -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Sequence from typing import Any from openfga_sdk.api.open_fga_api import OpenFgaApi @@ -37,6 +37,7 @@ construct_write_single_response, ) from openfga_sdk.client.models.write_transaction_opts import WriteTransactionOpts +from openfga_sdk.client.request import parse_request from openfga_sdk.constants import ( CLIENT_BULK_REQUEST_ID_HEADER, CLIENT_MAX_BATCH_SIZE, @@ -273,7 +274,7 @@ async def list_stores( async def create_store( self, - body: CreateStoreRequest, + body: CreateStoreRequest | dict[str, Any], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -283,6 +284,7 @@ async def create_store( :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 """ + body = parse_request(body, CreateStoreRequest) kwargs = options_to_kwargs(options) api_response = await self._api.create_store(body, **kwargs) return api_response @@ -341,7 +343,7 @@ async def read_authorization_models( async def write_authorization_model( self, - body: WriteAuthorizationModelRequest, + body: WriteAuthorizationModelRequest | dict[str, Any], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -352,6 +354,7 @@ async def write_authorization_model( :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 """ + body = parse_request(body, WriteAuthorizationModelRequest) kwargs = options_to_kwargs(options) api_response = await self._api.write_authorization_model( body, @@ -405,7 +408,7 @@ async def read_latest_authorization_model( async def read_changes( self, - body: ClientReadChangesRequest, + body: ClientReadChangesRequest | dict[str, Any], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -418,6 +421,7 @@ async def read_changes( :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 """ + body = parse_request(body, ClientReadChangesRequest) kwargs = options_to_kwargs(options) if body.type is not None: @@ -433,7 +437,7 @@ async def read_changes( async def read( self, - body: ReadRequestTupleKey, + body: ReadRequestTupleKey | dict[str, Any] | None, options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -447,6 +451,7 @@ async def read( :param retryParams.minWaitInMs(options) - Override the minimum wait before a retry is initiated :param consistency(options) - The type of consistency preferred for the request """ + body = parse_request(body, ReadRequestTupleKey) page_size = None continuation_token = None if options: @@ -576,7 +581,7 @@ async def _write_with_transaction( async def write( self, - body: ClientWriteRequest, + body: ClientWriteRequest | dict[str, Any], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -587,6 +592,7 @@ async def write( :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 """ + body = parse_request(body, ClientWriteRequest) options = set_heading_if_not_set(options, CLIENT_METHOD_HEADER, "Write") transaction = options_to_transaction_info(options) if not transaction.disabled: @@ -612,7 +618,7 @@ async def write( async def write_tuples( self, - body: list[ClientTuple], + body: Sequence[ClientTuple | dict[str, Any]], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -623,13 +629,14 @@ async def write_tuples( :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 """ + body = [parse_request(item, ClientTuple) for item in body] options = set_heading_if_not_set(options, CLIENT_METHOD_HEADER, "WriteTuples") result = await self.write(ClientWriteRequest(body, None), options) return result async def delete_tuples( self, - body: list[ClientTuple], + body: Sequence[ClientTuple | dict[str, Any]], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -640,6 +647,7 @@ async def delete_tuples( :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 """ + body = [parse_request(item, ClientTuple) for item in body] options = set_heading_if_not_set(options, CLIENT_METHOD_HEADER, "DeleteTuples") result = await self.write(ClientWriteRequest(None, body), options) return result @@ -649,7 +657,7 @@ async def delete_tuples( ####################### async def check( self, - body: ClientCheckRequest, + body: ClientCheckRequest | dict[str, Any], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -662,6 +670,7 @@ async def check( :param retryParams.minWaitInMs(options) - Override the minimum wait before a retry is initiated :param consistency(options) - The type of consistency preferred for the request """ + body = parse_request(body, ClientCheckRequest) kwargs = options_to_kwargs(options) req_body = CheckRequest( @@ -712,7 +721,7 @@ async def _single_client_batch_check( async def client_batch_check( self, - body: list[ClientCheckRequest], + body: Sequence[ClientCheckRequest | dict[str, Any]], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -725,6 +734,7 @@ async def client_batch_check( :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 """ + body = [parse_request(item, ClientCheckRequest) for item in body] options = set_heading_if_not_set(options, CLIENT_METHOD_HEADER, "BatchCheck") options = set_heading_if_not_set( options, CLIENT_BULK_REQUEST_ID_HEADER, str(uuid.uuid4()) @@ -771,7 +781,7 @@ async def _single_batch_check( async def batch_check( self, - body: ClientBatchCheckRequest, + body: ClientBatchCheckRequest | dict[str, Any], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -785,6 +795,7 @@ async def batch_check( :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 """ + body = parse_request(body, ClientBatchCheckRequest) options = set_heading_if_not_set( options, CLIENT_BULK_REQUEST_ID_HEADER, str(uuid.uuid4()) ) @@ -868,7 +879,7 @@ async def coro(checks): async def expand( self, - body: ClientExpandRequest, + body: ClientExpandRequest | dict[str, Any], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -881,6 +892,7 @@ async def expand( :param retryParams.minWaitInMs(options) - Override the minimum wait before a retry is initiated :param consistency(options) - The type of consistency preferred for the request """ + body = parse_request(body, ClientExpandRequest) kwargs = options_to_kwargs(options) req_body = ExpandRequest( @@ -900,7 +912,7 @@ async def expand( async def list_objects( self, - body: ClientListObjectsRequest, + body: ClientListObjectsRequest | dict[str, Any], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -913,6 +925,7 @@ async def list_objects( :param retryParams.minWaitInMs(options) - Override the minimum wait before a retry is initiated :param consistency(options) - The type of consistency preferred for the request """ + body = parse_request(body, ClientListObjectsRequest) kwargs = options_to_kwargs(options) req_body = ListObjectsRequest( @@ -932,7 +945,7 @@ async def list_objects( async def streamed_list_objects( self, - body: ClientListObjectsRequest, + body: ClientListObjectsRequest | dict[str, Any], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -946,6 +959,7 @@ async def streamed_list_objects( :param retryParams.minWaitInMs(options) - Override the minimum wait before a retry is initiated :param consistency(options) - The type of consistency preferred for the request11 """ + body = parse_request(body, ClientListObjectsRequest) kwargs = options_to_kwargs(options) kwargs["_streaming"] = True @@ -971,7 +985,7 @@ async def streamed_list_objects( async def list_relations( self, - body: ClientListRelationsRequest, + body: ClientListRelationsRequest | dict[str, Any], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -984,6 +998,7 @@ async def list_relations( :param retryParams.minWaitInMs(options) - Override the minimum wait before a retry is initiated :param consistency(options) - The type of consistency preferred for the request """ + body = parse_request(body, ClientListRelationsRequest) options = set_heading_if_not_set(options, CLIENT_METHOD_HEADER, "ListRelations") options = set_heading_if_not_set( options, CLIENT_BULK_REQUEST_ID_HEADER, str(uuid.uuid4()) @@ -1014,7 +1029,7 @@ async def list_relations( async def list_users( self, - body: ClientListUsersRequest, + body: ClientListUsersRequest | dict[str, Any], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -1027,6 +1042,7 @@ async def list_users( :param retryParams.minWaitInMs(options) - Override the minimum wait before a retry is initiated :param consistency(options) - The type of consistency preferred for the request """ + body = parse_request(body, ClientListUsersRequest) kwargs = options_to_kwargs(options) req_body = ListUsersRequest( @@ -1068,7 +1084,7 @@ async def read_assertions( async def write_assertions( self, - body: list[ClientAssertion], + body: Sequence[ClientAssertion | dict[str, Any]], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -1080,6 +1096,7 @@ async def write_assertions( :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 """ + body = [parse_request(item, ClientAssertion) for item in body] kwargs = options_to_kwargs(options) authorization_model_id = self._get_authorization_model_id(options) diff --git a/openfga_sdk/client/request.py b/openfga_sdk/client/request.py new file mode 100644 index 00000000..b8361421 --- /dev/null +++ b/openfga_sdk/client/request.py @@ -0,0 +1,30 @@ +from typing import Any, TypeVar + +from openfga_sdk.client.models.batch_check_item import ClientBatchCheckItem +from openfga_sdk.client.models.batch_check_request import ClientBatchCheckRequest +from openfga_sdk.client.models.tuple import ClientTuple +from openfga_sdk.client.models.write_request import ClientWriteRequest + + +Request = TypeVar("Request") + + +def parse_request(body: Request | dict[str, Any], model: type[Request]) -> Request: + """Construct a request from constructor arguments without changing the input.""" + if not isinstance(body, dict): + return body + + fields = dict(body) + nested_lists: dict[str, type[Any]] = {"contextual_tuples": ClientTuple} + if model is ClientWriteRequest: + nested_lists.update(writes=ClientTuple, deletes=ClientTuple) + elif model is ClientBatchCheckRequest: + nested_lists["checks"] = ClientBatchCheckItem + + for field, nested_model in nested_lists.items(): + if fields.get(field) is not None: + fields[field] = [ + parse_request(item, nested_model) for item in fields[field] + ] + + return model(**fields) diff --git a/openfga_sdk/sync/client/client.py b/openfga_sdk/sync/client/client.py index c736daec..bebbf3ce 100644 --- a/openfga_sdk/sync/client/client.py +++ b/openfga_sdk/sync/client/client.py @@ -1,6 +1,6 @@ import uuid -from collections.abc import Iterator +from collections.abc import Iterator, Sequence from concurrent.futures import ThreadPoolExecutor from typing import Any @@ -35,6 +35,7 @@ construct_write_single_response, ) from openfga_sdk.client.models.write_transaction_opts import WriteTransactionOpts +from openfga_sdk.client.request import parse_request from openfga_sdk.constants import ( CLIENT_BULK_REQUEST_ID_HEADER, CLIENT_MAX_BATCH_SIZE, @@ -273,7 +274,7 @@ def list_stores( def create_store( self, - body: CreateStoreRequest, + body: CreateStoreRequest | dict[str, Any], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -283,6 +284,7 @@ def create_store( :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 """ + body = parse_request(body, CreateStoreRequest) kwargs = options_to_kwargs(options) api_response = self._api.create_store(body, **kwargs) return api_response @@ -341,7 +343,7 @@ def read_authorization_models( def write_authorization_model( self, - body: WriteAuthorizationModelRequest, + body: WriteAuthorizationModelRequest | dict[str, Any], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -352,6 +354,7 @@ def write_authorization_model( :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 """ + body = parse_request(body, WriteAuthorizationModelRequest) kwargs = options_to_kwargs(options) api_response = self._api.write_authorization_model( body, @@ -405,7 +408,7 @@ def read_latest_authorization_model( def read_changes( self, - body: ClientReadChangesRequest, + body: ClientReadChangesRequest | dict[str, Any], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -418,6 +421,7 @@ def read_changes( :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 """ + body = parse_request(body, ClientReadChangesRequest) kwargs = options_to_kwargs(options) if body.type is not None: @@ -433,7 +437,7 @@ def read_changes( def read( self, - body: ReadRequestTupleKey, + body: ReadRequestTupleKey | dict[str, Any] | None, options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -447,6 +451,7 @@ def read( :param retryParams.minWaitInMs(options) - Override the minimum wait before a retry is initiated :param consistency(options) - The type of consistency preferred for the request """ + body = parse_request(body, ReadRequestTupleKey) page_size = None continuation_token = None if options: @@ -575,7 +580,7 @@ def _write_with_transaction( def write( self, - body: ClientWriteRequest, + body: ClientWriteRequest | dict[str, Any], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -586,6 +591,7 @@ def write( :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 """ + body = parse_request(body, ClientWriteRequest) options = set_heading_if_not_set(options, CLIENT_METHOD_HEADER, "Writes") transaction = options_to_transaction_info(options) if not transaction.disabled: @@ -611,7 +617,7 @@ def write( def write_tuples( self, - body: list[ClientTuple], + body: Sequence[ClientTuple | dict[str, Any]], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -622,13 +628,14 @@ def write_tuples( :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 """ + body = [parse_request(item, ClientTuple) for item in body] options = set_heading_if_not_set(options, CLIENT_METHOD_HEADER, "WriteTuples") result = self.write(ClientWriteRequest(body, None), options) return result def delete_tuples( self, - body: list[ClientTuple], + body: Sequence[ClientTuple | dict[str, Any]], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -639,6 +646,7 @@ def delete_tuples( :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 """ + body = [parse_request(item, ClientTuple) for item in body] options = set_heading_if_not_set(options, CLIENT_METHOD_HEADER, "DeleteTuples") result = self.write(ClientWriteRequest(None, body), options) return result @@ -648,7 +656,7 @@ def delete_tuples( ####################### def check( self, - body: ClientCheckRequest, + body: ClientCheckRequest | dict[str, Any], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -661,6 +669,7 @@ def check( :param retryParams.minWaitInMs(options) - Override the minimum wait before a retry is initiated :param consistency(options) - The type of consistency preferred for the request """ + body = parse_request(body, ClientCheckRequest) kwargs = options_to_kwargs(options) req_body = CheckRequest( @@ -707,7 +716,7 @@ def _single_client_batch_check( def client_batch_check( self, - body: list[ClientCheckRequest], + body: Sequence[ClientCheckRequest | dict[str, Any]], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -721,6 +730,7 @@ def client_batch_check( :param retryParams.minWaitInMs(options) - Override the minimum wait before a retry is initiated :param consistency(options) - The type of consistency preferred for the request """ + body = [parse_request(item, ClientCheckRequest) for item in body] options = set_heading_if_not_set(options, CLIENT_METHOD_HEADER, "BatchCheck") options = set_heading_if_not_set( options, CLIENT_BULK_REQUEST_ID_HEADER, str(uuid.uuid4()) @@ -767,7 +777,7 @@ def _single_batch_check( def batch_check( self, - body: ClientBatchCheckRequest, + body: ClientBatchCheckRequest | dict[str, Any], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -781,6 +791,7 @@ def batch_check( :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 """ + body = parse_request(body, ClientBatchCheckRequest) options = set_heading_if_not_set( options, CLIENT_BULK_REQUEST_ID_HEADER, str(uuid.uuid4()) ) @@ -866,7 +877,7 @@ def single_batch_check(checks): def expand( self, - body: ClientExpandRequest, + body: ClientExpandRequest | dict[str, Any], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -879,6 +890,7 @@ def expand( :param retryParams.minWaitInMs(options) - Override the minimum wait before a retry is initiated :param consistency(options) - The type of consistency preferred for the request """ + body = parse_request(body, ClientExpandRequest) kwargs = options_to_kwargs(options) req_body = ExpandRequest( @@ -898,7 +910,7 @@ def expand( def list_objects( self, - body: ClientListObjectsRequest, + body: ClientListObjectsRequest | dict[str, Any], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -911,6 +923,7 @@ def list_objects( :param retryParams.minWaitInMs(options) - Override the minimum wait before a retry is initiated :param consistency(options) - The type of consistency preferred for the request """ + body = parse_request(body, ClientListObjectsRequest) kwargs = options_to_kwargs(options) req_body = ListObjectsRequest( @@ -930,7 +943,7 @@ def list_objects( def streamed_list_objects( self, - body: ClientListObjectsRequest, + body: ClientListObjectsRequest | dict[str, Any], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -944,6 +957,7 @@ def streamed_list_objects( :param retryParams.minWaitInMs(options) - Override the minimum wait before a retry is initiated :param consistency(options) - The type of consistency preferred for the request """ + body = parse_request(body, ClientListObjectsRequest) kwargs = options_to_kwargs(options) kwargs["_streaming"] = True @@ -969,7 +983,7 @@ def streamed_list_objects( def list_relations( self, - body: ClientListRelationsRequest, + body: ClientListRelationsRequest | dict[str, Any], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -982,6 +996,7 @@ def list_relations( :param retryParams.minWaitInMs(options) - Override the minimum wait before a retry is initiated :param consistency(options) - The type of consistency preferred for the request """ + body = parse_request(body, ClientListRelationsRequest) options = set_heading_if_not_set(options, CLIENT_METHOD_HEADER, "ListRelations") options = set_heading_if_not_set( options, CLIENT_BULK_REQUEST_ID_HEADER, str(uuid.uuid4()) @@ -1012,7 +1027,7 @@ def list_relations( def list_users( self, - body: ClientListUsersRequest, + body: ClientListUsersRequest | dict[str, Any], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -1025,6 +1040,7 @@ def list_users( :param retryParams.minWaitInMs(options) - Override the minimum wait before a retry is initiated :param consistency(options) - The type of consistency preferred for the request """ + body = parse_request(body, ClientListUsersRequest) kwargs = options_to_kwargs(options) req_body = ListUsersRequest( @@ -1066,7 +1082,7 @@ def read_assertions( def write_assertions( self, - body: list[ClientAssertion], + body: Sequence[ClientAssertion | dict[str, Any]], options: dict[str, int | str | dict[str, int | str]] | None = None, ): """ @@ -1078,6 +1094,7 @@ def write_assertions( :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 """ + body = [parse_request(item, ClientAssertion) for item in body] kwargs = options_to_kwargs(options) authorization_model_id = self._get_authorization_model_id(options) diff --git a/test/client/dictionary_requests_test.py b/test/client/dictionary_requests_test.py new file mode 100644 index 00000000..f1f14342 --- /dev/null +++ b/test/client/dictionary_requests_test.py @@ -0,0 +1,366 @@ +"""Dictionary requests must reach the wire in the same shape as SDK models.""" + +import copy +import inspect +import json + +from unittest.mock import AsyncMock, Mock, patch + +import pytest +import pytest_asyncio +import urllib3 + +from openfga_sdk.client import ClientConfiguration, OpenFgaClient +from openfga_sdk.client.models.check_request import ClientCheckRequest +from openfga_sdk.client.models.tuple import ClientTuple +from openfga_sdk.client.models.write_transaction_opts import WriteTransactionOpts +from openfga_sdk.rest import RESTResponse +from openfga_sdk.sync import OpenFgaClient as SyncOpenFgaClient + + +STORE_ID = "01YCP46JKYM8FJCQ37NMBYHE5X" +MODEL_ID = "01GXSA8YR785C4FYS3C0RTG7B1" +TUPLE = {"user": "user:bob", "relation": "viewer", "object": "document:roadmap"} +CONDITION = {"name": "valid_grant", "context": {"expires_at": "2026-12-01T00:00:00Z"}} +CONDITIONAL_TUPLE = {**TUPLE, "condition": CONDITION} +CONTEXT = {"current_time": "2026-11-01T00:00:00Z", "attributes": {"verified": True}} +CHECK = {**TUPLE, "contextual_tuples": [CONDITIONAL_TUPLE], "context": CONTEXT} +CHECK_PAYLOAD = { + "tuple_key": TUPLE, + "contextual_tuples": {"tuple_keys": [CONDITIONAL_TUPLE]}, + "context": CONTEXT, + "authorization_model_id": MODEL_ID, +} +LIST_OBJECTS = { + "user": "user:bob", + "relation": "viewer", + "type": "document", + "contextual_tuples": [CONDITIONAL_TUPLE], + "context": CONTEXT, +} +LIST_OBJECTS_PAYLOAD = { + **LIST_OBJECTS, + "contextual_tuples": {"tuple_keys": [CONDITIONAL_TUPLE]}, + "authorization_model_id": MODEL_ID, +} + +# Each expected payload describes the public API contract, not parser internals. +CASES = [ + ( + "create_store", + {"name": "dictionary store"}, + {"id": STORE_ID, "name": "dictionary store"}, + {"name": "dictionary store"}, + ), + ( + "write_authorization_model", + {"schema_version": "1.1", "type_definitions": [{"type": "user"}]}, + {"authorization_model_id": MODEL_ID}, + {"schema_version": "1.1", "type_definitions": [{"type": "user"}]}, + ), + ("read", TUPLE, {"tuples": [], "continuation_token": ""}, {"tuple_key": TUPLE}), + ("read", {}, {"tuples": [], "continuation_token": ""}, {}), + ("read", None, {"tuples": [], "continuation_token": ""}, {}), + ( + "read_changes", + {"type": "document", "start_time": "2026-01-01T00:00:00Z"}, + {"changes": [], "continuation_token": ""}, + None, + ), + ("check", CHECK, {"allowed": True}, CHECK_PAYLOAD), + ("client_batch_check", [CHECK], {"allowed": True}, CHECK_PAYLOAD), + ( + "batch_check", + {"checks": [{**CHECK, "correlation_id": "first"}]}, + {"result": {"first": {"allowed": True}}}, + { + "checks": [ + { + "tuple_key": TUPLE, + "contextual_tuples": {"tuple_keys": [CONDITIONAL_TUPLE]}, + "context": CONTEXT, + "correlation_id": "first", + } + ], + "authorization_model_id": MODEL_ID, + }, + ), + ( + "write", + {"writes": [CONDITIONAL_TUPLE], "deletes": [TUPLE]}, + {}, + { + "writes": {"tuple_keys": [CONDITIONAL_TUPLE], "on_duplicate": "error"}, + "deletes": {"tuple_keys": [TUPLE], "on_missing": "error"}, + "authorization_model_id": MODEL_ID, + }, + ), + ( + "write_tuples", + [CONDITIONAL_TUPLE], + {}, + { + "writes": {"tuple_keys": [CONDITIONAL_TUPLE], "on_duplicate": "error"}, + "authorization_model_id": MODEL_ID, + }, + ), + ( + "delete_tuples", + [TUPLE], + {}, + { + "deletes": {"tuple_keys": [TUPLE], "on_missing": "error"}, + "authorization_model_id": MODEL_ID, + }, + ), + ( + "expand", + { + "relation": "viewer", + "object": "document:roadmap", + "contextual_tuples": [CONDITIONAL_TUPLE], + }, + {"tree": {"root": {"name": "document:roadmap#viewer"}}}, + { + "tuple_key": {"relation": "viewer", "object": "document:roadmap"}, + "contextual_tuples": {"tuple_keys": [CONDITIONAL_TUPLE]}, + "authorization_model_id": MODEL_ID, + }, + ), + ("list_objects", LIST_OBJECTS, {"objects": []}, LIST_OBJECTS_PAYLOAD), + ( + "list_relations", + { + "user": "user:bob", + "relations": ["viewer"], + "object": "document:roadmap", + "contextual_tuples": [CONDITIONAL_TUPLE], + "context": CONTEXT, + }, + {"allowed": True}, + CHECK_PAYLOAD, + ), + ( + "list_users", + { + "object": {"type": "document", "id": "roadmap"}, + "relation": "viewer", + "user_filters": [{"type": "user"}], + "contextual_tuples": [CONDITIONAL_TUPLE], + "context": CONTEXT, + }, + {"users": []}, + { + "object": {"type": "document", "id": "roadmap"}, + "relation": "viewer", + "user_filters": [{"type": "user"}], + "contextual_tuples": [CONDITIONAL_TUPLE], + "context": CONTEXT, + "authorization_model_id": MODEL_ID, + }, + ), + ( + "write_assertions", + [{**TUPLE, "expectation": True}], + {}, + {"assertions": [{"tuple_key": TUPLE, "expectation": True}]}, + ), +] + + +@pytest_asyncio.fixture(params=["async", "sync"]) +async def client(request): + configuration = ClientConfiguration( + api_url="http://api.fga.example", + store_id=STORE_ID, + authorization_model_id=MODEL_ID, + ) + cls = OpenFgaClient if request.param == "async" else SyncOpenFgaClient + return cls(configuration) + + +async def invoke(client, method, body, options=None): + result = getattr(client, method)(body, options) + return await result if inspect.isawaitable(result) else result + + +async def close(client): + result = client.close() + if inspect.isawaitable(result): + await result + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "method,body,response,expected", CASES, ids=[case[0] for case in CASES] +) +async def test_dictionary_request_serialization( + client, method, body, response, expected +): + body = copy.deepcopy(body) + original = copy.deepcopy(body) + data = json.dumps(response).encode() + http_response = urllib3.HTTPResponse( + data, + status=200, + headers={"content-type": "application/json"}, + ) + response = RESTResponse(http_response, data) + mock_cls = AsyncMock if isinstance(client, OpenFgaClient) else Mock + with patch.object( + client._api_client.rest_client, "request", new_callable=mock_cls + ) as request: + request.return_value = response + try: + await invoke(client, method, body) + request.assert_called_once() + assert request.call_args.kwargs["body"] == expected + if method == "read_changes": + assert dict(request.call_args.kwargs["query_params"]) == original + assert body == original + finally: + await close(client) + + +@pytest.mark.asyncio +async def test_streamed_dictionary_request(client): + body = copy.deepcopy(LIST_OBJECTS) + + async def async_responses(): + yield {"result": {"object": "document:roadmap"}} + + mock_cls = AsyncMock if isinstance(client, OpenFgaClient) else Mock + with patch.object( + client._api, "streamed_list_objects", new_callable=mock_cls + ) as request: + request.return_value = ( + async_responses() + if isinstance(client, OpenFgaClient) + else iter([{"result": {"object": "document:roadmap"}}]) + ) + try: + stream = client.streamed_list_objects(body) + results = ( + [item async for item in stream] + if isinstance(client, OpenFgaClient) + else list(stream) + ) + assert [item.object for item in results] == ["document:roadmap"] + payload = client._api_client.sanitize_for_serialization( + request.call_args.kwargs["body"] + ) + assert payload == LIST_OBJECTS_PAYLOAD + assert body == LIST_OBJECTS + finally: + await close(client) + + +@pytest.mark.asyncio +async def test_batch_check_does_not_insert_correlation_id_in_dictionary(client): + body = {"checks": [copy.deepcopy(CHECK)]} + original = copy.deepcopy(body) + mock_cls = AsyncMock if isinstance(client, OpenFgaClient) else Mock + with patch.object(client._api, "batch_check", new_callable=mock_cls) as request: + request.return_value = Mock(result={}) + try: + await invoke(client, "batch_check", body) + sent_check = request.call_args.args[0].checks[0] + assert sent_check.correlation_id + assert body == original + finally: + await close(client) + + +@pytest.mark.asyncio +async def test_dictionary_can_contain_existing_tuple_models(client): + contextual_tuple = ClientTuple(**TUPLE) + body = {**TUPLE, "contextual_tuples": [contextual_tuple, copy.deepcopy(TUPLE)]} + mock_cls = AsyncMock if isinstance(client, OpenFgaClient) else Mock + with patch.object(client._api, "check", new_callable=mock_cls) as request: + try: + await invoke(client, "check", body) + payload = client._api_client.sanitize_for_serialization( + request.call_args.kwargs["body"] + ) + assert payload["contextual_tuples"]["tuple_keys"] == [TUPLE, TUPLE] + assert body["contextual_tuples"][0] is contextual_tuple + finally: + await close(client) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "body", + [ + {"relation": "viewer"}, + {**TUPLE, "unexpected": True}, + {**TUPLE, "contextual_tuples": [{"user": "user:bob"}]}, + ], +) +async def test_invalid_dictionary_is_rejected_before_http_request(client, body): + mock_cls = AsyncMock if isinstance(client, OpenFgaClient) else Mock + with patch.object(client._api, "check", new_callable=mock_cls) as request: + try: + with pytest.raises(TypeError): + await invoke(client, "check", body) + request.assert_not_called() + finally: + await close(client) + + +def test_existing_request_models_are_preserved(): + from openfga_sdk.client.request import parse_request + + request = ClientCheckRequest(**TUPLE) + assert parse_request(request, ClientCheckRequest) is request + + +@pytest.mark.asyncio +async def test_dictionary_write_without_transaction(client): + body = {"writes": [copy.deepcopy(CONDITIONAL_TUPLE), copy.deepcopy(TUPLE)]} + original = copy.deepcopy(body) + mock_cls = AsyncMock if isinstance(client, OpenFgaClient) else Mock + with patch.object(client._api, "write", new_callable=mock_cls) as request: + try: + result = await invoke( + client, + "write", + body, + {"transaction": WriteTransactionOpts(disabled=True, max_per_chunk=1)}, + ) + assert request.call_count == 2 + payloads = [ + client._api_client.sanitize_for_serialization(call.args[0]) + for call in request.call_args_list + ] + assert [payload["writes"]["tuple_keys"] for payload in payloads] == [ + [CONDITIONAL_TUPLE], + [TUPLE], + ] + assert all(item.success for item in result.writes) + assert body == original + finally: + await close(client) + + +@pytest.mark.asyncio +async def test_dictionary_batch_check_across_chunks(client): + body = { + "checks": [ + {**TUPLE, "correlation_id": "first"}, + {**TUPLE, "correlation_id": "second"}, + ] + } + original = copy.deepcopy(body) + mock_cls = AsyncMock if isinstance(client, OpenFgaClient) else Mock + with patch.object(client._api, "batch_check", new_callable=mock_cls) as request: + request.return_value = Mock(result={}) + try: + await invoke(client, "batch_check", body, {"max_batch_size": 1}) + assert request.call_count == 2 + assert { + call.args[0].checks[0].correlation_id for call in request.call_args_list + } == {"first", "second"} + assert body == original + finally: + await close(client)