diff --git a/Makefile b/Makefile index f4cacff8..1be7cef4 100644 --- a/Makefile +++ b/Makefile @@ -144,8 +144,7 @@ build-client-python: sort -uo ${CLIENTS_OUTPUT_DIR}/fga-python-sdk/.openapi-generator/FILES{,} - make run-in-docker sdk_language=python image=busybox:${BUSYBOX_DOCKER_TAG} command="/bin/sh -c 'patch -p1 /module/openfga_sdk/api/open_fga_api.py /config/clients/python/patches/open_fga_api.py.patch && \ - patch -p1 /module/docs/OpenFgaApi.md /config/clients/python/patches/OpenFgaApi.md.patch'" + make run-in-docker sdk_language=python image=busybox:${BUSYBOX_DOCKER_TAG} command="/bin/sh -c 'patch -p1 /module/docs/OpenFgaApi.md /config/clients/python/patches/OpenFgaApi.md.patch'" make run-in-docker sdk_language=python image=ghcr.io/astral-sh/uv:python${PYTHON_DOCKER_TAG}-alpine command="/bin/sh -c 'export UV_LINK_MODE=copy && \ uv sync && \ diff --git a/config/clients/python/.openapi-generator-ignore b/config/clients/python/.openapi-generator-ignore index 2d6e1835..d7decf7b 100644 --- a/config/clients/python/.openapi-generator-ignore +++ b/config/clients/python/.openapi-generator-ignore @@ -14,5 +14,7 @@ openfga_sdk/api/open_fga_api openfga_sdk/sync/open_fga_api.py openfga_sdk/api_client.py openfga_sdk/configuration.py +openfga_sdk/_version.py openfga_sdk/exceptions.py -openfga_sdk/rest.py \ No newline at end of file +openfga_sdk/rest.py +VERSION.txt diff --git a/config/clients/python/config.overrides.json b/config/clients/python/config.overrides.json index 027930c3..bd6000e7 100644 --- a/config/clients/python/config.overrides.json +++ b/config/clients/python/config.overrides.json @@ -2,7 +2,7 @@ "sdkId": "python", "gitRepoId": "python-sdk", "packageName": "openfga_sdk", - "packageVersion": "0.9.9", + "packageVersion": "0.10.3", "packageDescription": "Python SDK for OpenFGA", "packageDetailedDescription": "This is an autogenerated python SDK for OpenFGA. It provides a wrapper around the [OpenFGA API definition](https://openfga.dev/api).", "fossaComplianceNoticeId": "2f8a8629-b46c-435e-b8cd-1174a674fb4b", @@ -12,6 +12,7 @@ "pythonMinimumRuntime": "3.10", "openTelemetryDocumentation": "docs/opentelemetry.md", "supportsStreamedListObjects": "streamed_list_objects", + "supportsCallingOtherEndpoints": true, "files": { "src/constants.mustache": { "destinationFilename": "openfga_sdk/constants.py", diff --git a/config/clients/python/template/README_calling_api.mustache b/config/clients/python/template/README_calling_api.mustache index ebffc901..29cf3b8e 100644 --- a/config/clients/python/template/README_calling_api.mustache +++ b/config/clients/python/template/README_calling_api.mustache @@ -962,3 +962,94 @@ body = [ClientAssertion( response = await fga_client.write_assertions(body, options) ``` + +### Calling Other Endpoints + +In certain cases you may want to call other APIs not yet wrapped by the SDK. You can do so by using the `execute_api_request` method available on the `OpenFgaClient`. It allows you to make raw HTTP calls to any OpenFGA endpoint by specifying the HTTP method, path, body, query parameters, and path parameters, while still honoring the client configuration (authentication, telemetry, retries, and error handling). + +For streaming endpoints (e.g. `streamed-list-objects`), use `execute_streamed_api_request` instead. It returns an `AsyncIterator` (or `Iterator` in the sync client) that yields one parsed JSON object per chunk. + +This is useful when: +- You want to call a new endpoint that is not yet supported by the SDK +- You are using an earlier version of the SDK that doesn't yet support a particular endpoint +- You have a custom endpoint deployed that extends the OpenFGA API + +#### Example: Calling a Custom Endpoint with POST + +```python +# Call a custom endpoint using path parameters +response = await fga_client.execute_api_request( + operation_name="CustomEndpoint", # For telemetry/logging + method="POST", + path="/stores/{store_id}/custom-endpoint", + path_params={"store_id": FGA_STORE_ID}, + body={ + "user": "user:bob", + "action": "custom_action", + "resource": "resource:123", + }, + query_params={ + "page_size": 20, + }, +) + +# Access the response data +if response.status == 200: + result = response.json() + print(f"Response: {result}") +``` + +#### Example: Calling an existing endpoint with GET + +```python +# Get a list of stores with query parameters +stores_response = await fga_client.execute_api_request( + operation_name="ListStores", + method="GET", + path="/stores", + query_params={ + "page_size": 10, + "continuation_token": "eyJwayI6...", + }, +) + +stores = stores_response.json() +print("Stores:", stores) +``` + +#### Example: Calling a Streaming Endpoint + +```python +# Stream objects visible to a user +async for chunk in fga_client.execute_streamed_api_request( + operation_name="StreamedListObjects", + method="POST", + path="/stores/{store_id}/streamed-list-objects", + path_params={"store_id": FGA_STORE_ID}, + body={ + "type": "document", + "relation": "viewer", + "user": "user:anne", + "authorization_model_id": FGA_MODEL_ID, + }, +): + # Each chunk has the shape {"result": {"object": "..."}} or {"error": {...}} + if "result" in chunk: + print(chunk["result"]["object"]) # e.g. "document:roadmap" +``` + +#### Example: Using Path Parameters + +Path parameters are specified in the path using `{param_name}` syntax and must all be provided explicitly via `path_params` (URL-encoded automatically): + +```python +response = await fga_client.execute_api_request( + operation_name="GetAuthorizationModel", + method="GET", + path="/stores/{store_id}/authorization-models/{model_id}", + path_params={ + "store_id": "your-store-id", + "model_id": "your-model-id", + }, +) +``` diff --git a/config/clients/python/template/README_custom_badges.mustache b/config/clients/python/template/README_custom_badges.mustache index f10611a6..531915b3 100644 --- a/config/clients/python/template/README_custom_badges.mustache +++ b/config/clients/python/template/README_custom_badges.mustache @@ -1,3 +1,3 @@ [![pypi](https://img.shields.io/pypi/v/{{packageName}}.svg?style=flat)](https://pypi.org/project/{{packageName}}) -[![Socket Badge](https://badge.socket.dev/pypi/package/openfga-sdk/{{packageVersion}})](https://socket.dev/pypi/package/openfga-sdk) +[![Socket Badge](https://badge.socket.dev/pypi/package/openfga-sdk/{{packageVersion}})](https://socket.dev/pypi/package/openfga-sdk) [![DeepWiki](https://img.shields.io/badge/DeepWiki-openfga%2Fpython--sdk-blue.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==)](https://deepwiki.com/openfga/python-sdk) diff --git a/config/clients/python/template/README_initializing.mustache b/config/clients/python/template/README_initializing.mustache index b73b60f5..a0487ece 100644 --- a/config/clients/python/template/README_initializing.mustache +++ b/config/clients/python/template/README_initializing.mustache @@ -17,7 +17,6 @@ async def main(): # Enter a context with an instance of the OpenFgaClient async with OpenFgaClient(configuration) as fga_client: api_response = await fga_client.read_authorization_models() - await fga_client.close() return api_response ``` @@ -43,7 +42,6 @@ async def main(): # Enter a context with an instance of the OpenFgaClient async with OpenFgaClient(configuration) as fga_client: api_response = await fga_client.read_authorization_models() - await fga_client.close() return api_response ``` @@ -63,16 +61,47 @@ async def main(): method='client_credentials', configuration=CredentialConfiguration( api_issuer=FGA_API_TOKEN_ISSUER, - api_audience=FGA_API_AUDIENCE, + api_audience=FGA_API_AUDIENCE, # optional, required for Auth0; omit for standard OAuth2 client_id=FGA_CLIENT_ID, client_secret=FGA_CLIENT_SECRET, + # scopes="read write", # optional, space-separated OAuth2 scopes ) ) ) # Enter a context with an instance of the OpenFgaClient async with OpenFgaClient(configuration) as fga_client: api_response = await fga_client.read_authorization_models() - await fga_client.close() + return api_response +``` + +> **Note:** `api_issuer` accepts either a hostname (e.g., `issuer.fga.example`, which defaults to `https:///oauth/token`) or a full token endpoint URL (e.g., `https://oauth.fga.example/token`). Use the full URL when your OAuth2 provider uses a non-standard token endpoint path. + +#### OAuth2 Client Credentials (Standard OAuth2) + +For OAuth2 providers that use `scope` instead of `audience`: + +```python +from {{packageName}} import ClientConfiguration, OpenFgaClient +from {{packageName}}.credentials import Credentials, CredentialConfiguration + + +async def main(): + configuration = ClientConfiguration( + api_url=FGA_API_URL, # required + store_id=FGA_STORE_ID, # optional + authorization_model_id=FGA_MODEL_ID, # optional + credentials=Credentials( + method='client_credentials', + configuration=CredentialConfiguration( + api_issuer="https://oauth.fga.example/token", # full token endpoint URL + client_id=FGA_CLIENT_ID, + client_secret=FGA_CLIENT_SECRET, + scopes="email profile", # space-separated OAuth2 scopes + ) + ) + ) + async with OpenFgaClient(configuration) as fga_client: + api_response = await fga_client.read_authorization_models() return api_response ``` diff --git a/config/clients/python/template/pyproject.toml.mustache b/config/clients/python/template/pyproject.toml.mustache index b40e00db..44da1bc4 100644 --- a/config/clients/python/template/pyproject.toml.mustache +++ b/config/clients/python/template/pyproject.toml.mustache @@ -129,11 +129,15 @@ testpaths = [ "integration", ] -addopts = "--cov=openfga_sdk --cov-report term-missing --cov-report xml --cov-report html" +addopts = "--cov=openfga_sdk --cov-report term-missing --cov-report xml --cov-report html --strict-markers" asyncio_mode = "strict" asyncio_default_fixture_loop_scope = "function" +markers = [ + "integration: marks tests as integration tests requiring a running OpenFGA server", +] + [tool.mypy] python_version = "{{pythonMinimumRuntime}}" packages = "openfga_sdk" diff --git a/config/clients/python/template/src/__init__.py.mustache b/config/clients/python/template/src/__init__.py.mustache index f7b0141c..4389505a 100644 --- a/config/clients/python/template/src/__init__.py.mustache +++ b/config/clients/python/template/src/__init__.py.mustache @@ -1,22 +1,21 @@ {{>partial_header}} -__version__ = "{{packageVersion}}" - -from {{packageName}}.client.client import OpenFgaClient -from {{packageName}}.client.configuration import ClientConfiguration - {{#apiInfo}}{{#apis}}from {{apiPackage}}.{{classFilename}} import {{classname}} {{/apis}}{{/apiInfo}} from {{packageName}}.api_client import ApiClient +from {{packageName}}.client.client import OpenFgaClient +from {{packageName}}.client.configuration import ClientConfiguration +from {{packageName}}.client.models.raw_response import RawResponse from {{packageName}}.configuration import Configuration - -from {{packageName}}.exceptions import OpenApiException -from {{packageName}}.exceptions import FgaValidationException -from {{packageName}}.exceptions import ApiValueError -from {{packageName}}.exceptions import ApiKeyError -from {{packageName}}.exceptions import ApiAttributeError -from {{packageName}}.exceptions import ApiException - +from {{packageName}}._version import SDK_VERSION +from {{packageName}}.exceptions import ( + ApiAttributeError, + ApiException, + ApiKeyError, + ApiValueError, + FgaValidationException, + OpenApiException, +) {{#models}}{{#model}}from {{modelPackage}}.{{classFilename}} import {{classname}} {{/model}}{{/models}} from {{packageName}}.telemetry.configuration import ( @@ -31,9 +30,12 @@ from {{packageName}}.telemetry.configuration import ( __import__('sys').setrecursionlimit({{{.}}}) {{/recursionLimit}} +__version__ = SDK_VERSION + __all__ = [ "OpenFgaClient", "ClientConfiguration", + "RawResponse", {{#apiInfo}}{{#apis}}"{{classname}}", {{/apis}}{{/apiInfo}} "ApiClient", diff --git a/config/clients/python/template/src/api.py.mustache b/config/clients/python/template/src/api.py.mustache index 7ba58f99..3850da3d 100644 --- a/config/clients/python/template/src/api.py.mustache +++ b/config/clients/python/template/src/api.py.mustache @@ -1,5 +1,10 @@ {{>partial_header}} +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Any + from {{packageName}}.api_client import ApiClient from {{packageName}}.exceptions import ApiValueError, FgaValidationException from {{packageName}}.oauth2 import OAuth2Client @@ -15,6 +20,24 @@ class {{classname}}: Do not edit the class manually. """ + _COMMON_PARAMS = [ + "async_req", + "_request_timeout", + "_headers", + "_retry_params", + "_streaming", + ] + + _COMMON_ERROR_RESPONSE_TYPES = { + 400: "ValidationErrorMessageResponse", + 401: "UnauthenticatedResponse", + 403: "ForbiddenResponse", + 404: "PathUnknownErrorMessageResponse", + 409: "AbortedMessageResponse", + 422: "UnprocessableContentMessageResponse", + 500: "InternalErrorMessageResponse", + } + def __init__(self, api_client=None): if api_client is None: api_client = ApiClient() @@ -30,7 +53,6 @@ class {{classname}}: self._telemetry = Telemetry() -{{#asyncio}} async def __aenter__(self): return self @@ -39,21 +61,324 @@ class {{classname}}: async def close(self): await self.api_client.close() -{{/asyncio}} -{{^asyncio}} - def __enter__(self): - return self - def __exit__(self): - self.close() + async def _execute( + self, + method: str, + path: str, + operation_name: str, + response_types_map: dict, + body=None, + query_params=None, + headers: dict | None = None, + options: dict | None = None, + ) -> Any: + """Shared executor for all API endpoint methods. + + Delegates to the public execute_api_request / execute_streamed_api_request + so all API calls share one unified code path. + """ + if options is None: + options = {} + + header_params = dict(headers or {}) + header_params["Accept"] = self.api_client.select_header_accept( + ["application/json"] + ) + if body is not None: + content_type = self.api_client.select_header_content_type( + ["application/json"], method, body + ) + if content_type: + header_params["Content-Type"] = content_type + + telemetry_attributes: dict[TelemetryAttribute, str | bool | int | float] = { + TelemetryAttributes.fga_client_request_method: operation_name, + TelemetryAttributes.fga_client_request_store_id: self.api_client.get_store_id(), + TelemetryAttributes.fga_client_request_model_id: options.get( + "authorization_model_id", "" + ), + } + telemetry_attributes = TelemetryAttributes.fromBody( + body=body, attributes=telemetry_attributes + ) + + if options.get("_streaming", False): + return self.execute_streamed_api_request( + operation_name=operation_name, + method=method, + path=path, + body=body, + query_params=query_params or [], + headers=header_params, + options=options, + response_types_map=response_types_map, + telemetry_attributes=telemetry_attributes, + _skip_builder=True, + ) + + return await self.execute_api_request( + operation_name=operation_name, + method=method, + path=path, + body=body, + query_params=query_params or [], + headers=header_params, + options=options, + response_types_map=response_types_map, + telemetry_attributes=telemetry_attributes, + _skip_builder=True, + ) + + async def execute_api_request( + self, + *, + operation_name: str, + method: str, + path: str, + path_params: dict[str, str] | None = None, + body: dict[str, Any] | list[Any] | str | bytes | None = None, + query_params: dict[str, str | int | list[str | int]] | list | None = None, + headers: dict[str, str] | None = None, + options: dict[str, Any] | None = None, + response_types_map: dict | None = None, + telemetry_attributes: dict | None = None, + _skip_builder: bool = False, + ) -> Any: + """ + Execute an arbitrary HTTP request to any OpenFGA API endpoint. + + Useful for calling endpoints not yet wrapped by the SDK while + still getting authentication, retries, and error handling. + + When called directly by users, returns a RawResponse. + When called internally with response_types_map, returns a deserialized typed model. + + :param operation_name: Operation name for telemetry (e.g., "CustomCheck") + :param method: HTTP method (GET, POST, PUT, DELETE, PATCH) + :param path: API path, e.g. "/stores/{store_id}/my-endpoint". + :param path_params: Path parameter substitutions (URL-encoded automatically). + All path parameters, including store_id, must be provided explicitly. + :param body: Request body for POST/PUT/PATCH + :param query_params: Query string parameters + :param headers: Custom headers (SDK enforces Content-Type and Accept) + :param options: Extra options e.g. {"retry_params": RetryParams(max_retry=3)} + :param response_types_map: When provided, merge with common error types and + return a deserialized typed model. When None, return RawResponse. + :param telemetry_attributes: When provided, use as-is for telemetry. + When None, build minimal defaults. + :param _skip_builder: When True, path/headers/query_params are used as-is + (already resolved by _execute). When False (default), run through + ExecuteApiRequestBuilder. + :return: RawResponse (public callers) or deserialized model (internal callers) + """ + from {{packageName}}.client.execute_api_request_builder import ( + ExecuteApiRequestBuilder, + ResponseParser, + ) + from {{packageName}}.client.models.raw_response import RawResponse + + if _skip_builder: + resource_path = path + query_params_list = query_params if query_params is not None else [] + final_headers = headers or {} + else: + builder = ExecuteApiRequestBuilder( + operation_name=operation_name, + method=method, + path=path, + path_params=path_params, + body=body, + query_params=query_params, + headers=headers, + ) + builder.validate() + + resource_path = builder.build_path() + query_params_list = builder.build_query_params_list() + final_headers = builder.build_headers() + + retry_params = ( + options.get("retry_params") or options.get("_retry_params") + if options + else None + ) + request_timeout = options.get("_request_timeout") if options else None + async_req = options.get("async_req") if options else None + + if telemetry_attributes is None: + telemetry_attributes = { + TelemetryAttributes.fga_client_request_method: operation_name.lower(), + } + if self.api_client.get_store_id(): + telemetry_attributes[ + TelemetryAttributes.fga_client_request_store_id + ] = self.api_client.get_store_id() + + if response_types_map is not None: + merged_response_types_map = { + **self._COMMON_ERROR_RESPONSE_TYPES, + **response_types_map, + } + return await self.api_client.call_api( + resource_path=resource_path, + method=method.upper(), + path_params={}, + query_params=query_params_list if query_params_list else [], + header_params=final_headers, + body=body, + post_params=[], + files={}, + response_types_map=merged_response_types_map, + auth_settings=[], + async_req=async_req, + _return_http_data_only=True, + _preload_content=True, + _request_timeout=request_timeout, + _retry_params=retry_params, + collection_formats={}, + _oauth2_client=self._oauth2_client, + _telemetry_attributes=telemetry_attributes, + _streaming=False, + ) + + # No response_types_map: public caller path → return RawResponse + await self.api_client.call_api( + resource_path=resource_path, + method=method.upper(), + query_params=query_params_list if query_params_list else None, + header_params=final_headers, + body=body, + response_types_map={}, + auth_settings=[], + _return_http_data_only=True, + _preload_content=True, + _retry_params=retry_params, + _oauth2_client=self._oauth2_client, + _telemetry_attributes=telemetry_attributes, + _streaming=False, + ) + + rest_response = getattr(self.api_client, "last_response", None) + if rest_response is None: + raise RuntimeError( + f"No response for {method.upper()} {resource_path} " + f"(operation: {operation_name})" + ) + + return RawResponse( + status=rest_response.status, + headers=dict(rest_response.getheaders()), + body=ResponseParser.parse_body(rest_response.data), + ) - def close(self): - self.api_client.close() -{{/asyncio}} + async def execute_streamed_api_request( + self, + *, + operation_name: str, + method: str, + path: str, + path_params: dict[str, str] | None = None, + body: dict[str, Any] | list[Any] | str | bytes | None = None, + query_params: dict[str, str | int | list[str | int]] | list | None = None, + headers: dict[str, str] | None = None, + options: dict[str, Any] | None = None, + response_types_map: dict | None = None, + telemetry_attributes: dict | None = None, + _skip_builder: bool = False, + ) -> AsyncIterator[dict[str, Any]]: + """ + Execute an arbitrary HTTP request to a streaming OpenFGA API endpoint. + + Yields parsed JSON objects as they arrive. Use with async for: + + async for chunk in api.execute_streamed_api_request(...): + process(chunk) + + :param operation_name: Operation name for telemetry (e.g., "StreamedListObjects") + :param method: HTTP method (GET, POST, PUT, DELETE, PATCH) + :param path: API path, e.g. "/stores/{store_id}/streamed-list-objects". + :param path_params: Path parameter substitutions (URL-encoded automatically). + All path parameters, including store_id, must be provided explicitly. + :param body: Request body for POST/PUT/PATCH + :param query_params: Query string parameters + :param headers: Custom headers (SDK enforces Content-Type and Accept) + :param options: Extra options e.g. {"retry_params": RetryParams(max_retry=3)} + :param response_types_map: When provided, merge with common error types. + :param telemetry_attributes: When provided, use as-is for telemetry. + :param _skip_builder: When True, path/headers/query_params are used as-is. + """ + if _skip_builder: + resource_path = path + query_params_list = query_params if query_params is not None else [] + final_headers = headers or {} + else: + from {{packageName}}.client.execute_api_request_builder import ( + ExecuteApiRequestBuilder, + ) + + builder = ExecuteApiRequestBuilder( + operation_name=operation_name, + method=method, + path=path, + path_params=path_params, + body=body, + query_params=query_params, + headers=headers, + ) + builder.validate() + + resource_path = builder.build_path() + query_params_list = builder.build_query_params_list() + final_headers = builder.build_headers() + + retry_params = ( + options.get("retry_params") or options.get("_retry_params") + if options + else None + ) + request_timeout = options.get("_request_timeout") if options else None + + if telemetry_attributes is None: + telemetry_attributes = { + TelemetryAttributes.fga_client_request_method: operation_name.lower(), + } + if self.api_client.get_store_id(): + telemetry_attributes[ + TelemetryAttributes.fga_client_request_store_id + ] = self.api_client.get_store_id() + + if response_types_map is not None: + merged_response_types_map = { + **self._COMMON_ERROR_RESPONSE_TYPES, + **response_types_map, + } + else: + merged_response_types_map = {} + + stream = await self.api_client.call_api( + resource_path=resource_path, + method=method.upper(), + query_params=query_params_list if query_params_list else None, + header_params=final_headers, + body=body, + response_types_map=merged_response_types_map, + auth_settings=[], + _return_http_data_only=True, + _preload_content=True, + _request_timeout=request_timeout, + _retry_params=retry_params, + _oauth2_client=self._oauth2_client, + _telemetry_attributes=telemetry_attributes, + _streaming=True, + ) + + async for chunk in stream: + yield chunk {{#operation}} - {{#asyncio}}async {{/asyncio}}def {{operationId}}(self, {{#sortParamsByRequiredFlag}}{{#allParams}}{{#required}}{{^-first}}{{paramName}}, {{/-first}}{{/required}}{{/allParams}}{{/sortParamsByRequiredFlag}}**kwargs): + async def {{operationId}}(self, {{#sortParamsByRequiredFlag}}{{#allParams}}{{#required}}{{#isPathParam}}{{^-first}}{{paramName}}, {{/-first}}{{/isPathParam}}{{^isPathParam}}{{paramName}}, {{/isPathParam}}{{/required}}{{/allParams}}{{/sortParamsByRequiredFlag}}**kwargs): """{{{summary}}}{{^summary}}{{operationId}}{{/summary}} {{#notes}} @@ -61,17 +386,20 @@ class {{classname}}: {{/notes}} {{#sortParamsByRequiredFlag}} - >>> thread = {{#asyncio}}await {{/asyncio}}api.{{operationId}}({{#allParams}}{{#required}}{{^-first}}{{paramName}}{{^-last}}, {{/-last}}{{/-first}}{{/required}}{{/allParams}}) -{{/sortParamsByRequiredFlag}} -{{^sortParamsByRequiredFlag}} - >>> thread = {{#asyncio}}await {{/asyncio}}api.{{operationId}}({{#allParams}}{{#required}}{{^-first}}{{paramName}}={{paramName}}_value{{^-last}}, {{/-last}} {{/-first}}{{/required}}{{/allParams}}) + >>> thread = await api.{{operationId}}({{#allParams}}{{#required}}{{#isPathParam}}{{^-first}}{{paramName}}{{^-last}}, {{/-last}}{{/-first}}{{/isPathParam}}{{^isPathParam}}{{paramName}}{{^-last}}, {{/-last}}{{/isPathParam}}{{/required}}{{/allParams}}) {{/sortParamsByRequiredFlag}} {{#requiredParams}} +{{#isPathParam}} {{^-first}} :param {{paramName}}:{{#description}} {{{.}}}{{/description}} (required) :type {{paramName}}: {{dataType}} {{/-first}} +{{/isPathParam}} +{{^isPathParam}} + :param {{paramName}}:{{#description}} {{{.}}}{{/description}} (required) + :type {{paramName}}: {{dataType}} +{{/isPathParam}} {{/requiredParams}} {{#optionalParams}} :param {{paramName}}:{{#description}} {{{.}}}{{/description}}(optional) @@ -92,10 +420,9 @@ class {{classname}}: returns the request thread. :rtype: {{returnType}}{{^returnType}}None{{/returnType}} """ - kwargs["_return_http_data_only"] = True - return {{#asyncio}}await {{/asyncio}}self.{{operationId}}_with_http_info({{#sortParamsByRequiredFlag}}{{#allParams}}{{#required}}{{^-first}}{{paramName}}, {{/-first}}{{/required}}{{/allParams}}{{/sortParamsByRequiredFlag}}**kwargs){{#asyncio}}{{/asyncio}} + return await self.{{operationId}}_with_http_info({{#sortParamsByRequiredFlag}}{{#allParams}}{{#required}}{{#isPathParam}}{{^-first}}{{paramName}}, {{/-first}}{{/isPathParam}}{{^isPathParam}}{{paramName}}, {{/isPathParam}}{{/required}}{{/allParams}}{{/sortParamsByRequiredFlag}}**kwargs) - {{#asyncio}}async {{/asyncio}}def {{operationId}}_with_http_info(self, {{#sortParamsByRequiredFlag}}{{#allParams}}{{#required}}{{^-first}}{{paramName}}, {{/-first}}{{/required}}{{/allParams}}{{/sortParamsByRequiredFlag}}**kwargs): + async def {{operationId}}_with_http_info(self, {{#sortParamsByRequiredFlag}}{{#allParams}}{{#required}}{{#isPathParam}}{{^-first}}{{paramName}}, {{/-first}}{{/isPathParam}}{{^isPathParam}}{{paramName}}, {{/isPathParam}}{{/required}}{{/allParams}}{{/sortParamsByRequiredFlag}}**kwargs): """{{{summary}}}{{^summary}}{{operationId}}{{/summary}} {{#notes}} @@ -103,17 +430,20 @@ class {{classname}}: {{/notes}} {{#sortParamsByRequiredFlag}} - >>> thread = api.{{operationId}}_with_http_info({{#allParams}}{{#required}}{{^-first}}{{paramName}}{{^-last}}, {{/-last}}{{/-first}}{{/required}}{{/allParams}}) -{{/sortParamsByRequiredFlag}} -{{^sortParamsByRequiredFlag}} - >>> thread = api.{{operationId}}_with_http_info({{#allParams}}{{#required}}{{^-first}}{{paramName}}={{paramName}}_value{{^-last}}, {{/-last}} {{/-first}}{{/required}}{{/allParams}}) + >>> thread = api.{{operationId}}_with_http_info({{#allParams}}{{#required}}{{#isPathParam}}{{^-first}}{{paramName}}{{^-last}}, {{/-last}}{{/-first}}{{/isPathParam}}{{^isPathParam}}{{paramName}}{{^-last}}, {{/-last}}{{/isPathParam}}{{/required}}{{/allParams}}) {{/sortParamsByRequiredFlag}} {{#requiredParams}} +{{#isPathParam}} {{^-first}} :param {{paramName}}:{{#description}} {{{.}}}{{/description}} (required) :type {{paramName}}: {{dataType}} {{/-first}} +{{/isPathParam}} +{{^isPathParam}} + :param {{paramName}}:{{#description}} {{{.}}}{{/description}} (required) + :type {{paramName}}: {{dataType}} +{{/isPathParam}} {{/requiredParams}} {{#optionalParams}} :param {{paramName}}:{{#description}} {{{.}}}{{/description}}(optional) @@ -144,187 +474,97 @@ class {{classname}}: :rtype: {{#returnType}}tuple({{.}}, status_code(int), headers(HTTPHeaderDict)){{/returnType}}{{^returnType}}None{{/returnType}} """ - {{#servers.0}} - local_var_hosts = [ -{{#servers}} - '{{{url}}}'{{^-last}},{{/-last}} -{{/servers}} - ] - local_var_host = local_var_hosts[0] - if kwargs.get('_host_index'): - _host_index = int(kwargs.get('_host_index')) - if _host_index < 0 or _host_index >= len(local_var_hosts): - raise ApiValueError( - "Invalid host index. Must be 0 <= index < %s" - % len(local_var_host) - ) - local_var_host = local_var_hosts[_host_index] - {{/servers.0}} local_var_params = locals() all_params = [ -{{#requiredParams}}{{^-first}} - '{{paramName}}'{{^-last}},{{/-last}} -{{/-first}}{{/requiredParams}} +{{#requiredParams}} +{{#isPathParam}} +{{^-first}} + "{{paramName}}", +{{/-first}} +{{/isPathParam}} +{{^isPathParam}} + "{{paramName}}", +{{/isPathParam}} +{{/requiredParams}} {{#optionalParams}} - '{{paramName}}'{{^-last}},{{/-last}} + "{{paramName}}", {{/optionalParams}} ] - all_params.extend( - [ - 'async_req', - '_return_http_data_only', - '_preload_content', - '_request_timeout', - '_request_auth', - '_content_type', - '_headers', - '_retry_params', - '_streaming', - ] - ) + all_params.extend(self._COMMON_PARAMS) - for key, val in local_var_params['kwargs'].items(): - if key not in all_params{{#servers.0}} and key != "_host_index"{{/servers.0}}: + for key, val in local_var_params["kwargs"].items(): + if key not in all_params: raise FgaValidationException( f"Got an unexpected keyword argument '{key}' to method {{operationId}}" ) local_var_params[key] = val - del local_var_params['kwargs'] + del local_var_params["kwargs"] {{#allParams}} {{^isNullable}} {{#required}} +{{#isPathParam}} {{^-first}} - # verify the required parameter '{{paramName}}' is set - if self.api_client.client_side_validation and local_var_params.get('{{paramName}}') is None: + if ( + self.api_client.client_side_validation + and local_var_params.get("{{paramName}}") is None + ): raise ApiValueError( - "Missing the required parameter `{{paramName}}` when calling `{{operationId}}`") + "Missing the required parameter `{{paramName}}` when calling `{{operationId}}`" + ) {{/-first}} +{{/isPathParam}} +{{^isPathParam}} + # verify the required parameter '{{paramName}}' is set + if ( + self.api_client.client_side_validation + and local_var_params.get("{{paramName}}") is None + ): + raise ApiValueError( + "Missing the required parameter `{{paramName}}` when calling `{{operationId}}`" + ) +{{/isPathParam}} {{/required}} {{/isNullable}} {{/allParams}} - -{{#allParams}} -{{#-last}} -{{/-last}} -{{/allParams}} - collection_formats = {} - - path_params = {} - - store_id = None {{#pathParams}} -{{^-first}} - if '{{paramName}}' in local_var_params: - path_params['{{baseName}}'] = local_var_params['{{paramName}}']{{#isArray}} - collection_formats['{{baseName}}'] = '{{collectionFormat}}'{{/isArray}} -{{/-first}} - {{#-first}} if self.api_client._get_store_id() is None: raise ApiValueError( - "Store ID expected in api_client's configuration when calling `{{operationId}}`") + "Store ID expected in api_client's configuration when calling `{{operationId}}`" + ) store_id = self.api_client._get_store_id() {{/-first}} - {{/pathParams}} - - query_params = [] {{#queryParams}} - if local_var_params.get('{{paramName}}') is not None: - query_params.append(('{{baseName}}', local_var_params['{{paramName}}'])){{#isArray}} - collection_formats['{{baseName}}'] = '{{collectionFormat}}'{{/isArray}} +{{#-first}} + query_params = [] +{{/-first}} + if local_var_params.get("{{paramName}}") is not None: + query_params.append(("{{baseName}}", local_var_params["{{paramName}}"])) {{/queryParams}} - - header_params = dict(local_var_params.get('_headers', {})) -{{#headerParams}} - if '{{paramName}}' in local_var_params: - header_params['{{baseName}}'] = local_var_params['{{paramName}}']{{#isArray}} - collection_formats['{{baseName}}'] = '{{collectionFormat}}'{{/isArray}} -{{/headerParams}} - - form_params = [] - local_var_files = {} -{{#formParams}} - if '{{paramName}}' in local_var_params: - {{^isFile}}form_params.append(('{{baseName}}', local_var_params['{{paramName}}'])){{/isFile}}{{#isFile}}local_var_files['{{baseName}}'] = local_var_params['{{paramName}}']{{/isFile}}{{#isArray}} - collection_formats['{{baseName}}'] = '{{collectionFormat}}'{{/isArray}} -{{/formParams}} - - body_params = None + return await self._execute( + method="{{httpMethod}}", + path={{#pathParams}}{{#-first}}f"{{/-first}}{{/pathParams}}{{^pathParams}}"{{/pathParams}}{{{path}}}", + operation_name="{{operationId}}", +{{#returnType}} +{{#responses}}{{#is2xx}} + response_types_map={ {{code}}: {{#dataType}}"{{.}}"{{/dataType}}{{^dataType}}None{{/dataType}}}, +{{/is2xx}}{{/responses}} +{{/returnType}} +{{^returnType}} + response_types_map={}, +{{/returnType}} {{#bodyParam}} - if '{{paramName}}' in local_var_params: - body_params = local_var_params['{{paramName}}'] + body=local_var_params.get("{{paramName}}"), {{/bodyParam}} - {{#hasProduces}} - # HTTP header `Accept` - header_params['Accept'] = self.api_client.select_header_accept( - [{{#produces}}'{{{mediaType}}}'{{^-last}}, {{/-last}}{{/produces}}]) - - {{/hasProduces}} - {{#hasConsumes}} - # HTTP header `Content-Type` - content_types_list = local_var_params.get('_content_type', self.api_client.select_header_content_type([{{#consumes}}'{{{mediaType}}}'{{^-last}}, {{/-last}}{{/consumes}}],'{{httpMethod}}', body_params)) - if content_types_list: - header_params['Content-Type'] = content_types_list - - {{/hasConsumes}} - # Authentication setting - auth_settings = [{{#authMethods}}'{{name}}'{{^-last}}, {{/-last}}{{/authMethods}}] - - {{#returnType}} - {{#responses}} - {{#-first}} - response_types_map = { - {{/-first}} - {{^isWildcard}} - {{code}}: {{#dataType}}"{{.}}"{{/dataType}}{{^dataType}}None{{/dataType}}, - {{/isWildcard}} - {{#-last}} - } - {{/-last}} - {{/responses}} - {{/returnType}} - {{^returnType}} - response_types_map = {} - {{/returnType}} - - telemetry_attributes: dict[TelemetryAttribute, str | bool | int | float] = { - TelemetryAttributes.fga_client_request_method: "{{operationId}}", - 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 {{#asyncio}}await ({{/asyncio}}self.api_client.call_api( - '{{{path}}}'.replace('{store_id}', store_id), '{{httpMethod}}', - 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'), - {{#servers.0}} - _host=local_var_host, - {{/servers.0}} - 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){{#asyncio}}){{/asyncio}} +{{#queryParams}} +{{#-first}} + query_params=query_params, +{{/-first}} +{{/queryParams}} + headers=local_var_params.get("_headers"), + options=local_var_params, ) {{/operation}} {{/operations}} diff --git a/config/clients/python/template/src/constants.mustache b/config/clients/python/template/src/constants.mustache index bdb76f80..38070302 100644 --- a/config/clients/python/template/src/constants.mustache +++ b/config/clients/python/template/src/constants.mustache @@ -2,11 +2,6 @@ from typing import Final -# Version of the OpenFGA Python SDK. -SDK_VERSION: Final[str] = "{{packageVersion}}" # x-release-please-version - -# User agent used in HTTP requests. -USER_AGENT: Final[str] = "{{userAgent}}" # Example API domain for documentation/tests. SAMPLE_BASE_DOMAIN: Final[str] = "{{sampleApiDomain}}" diff --git a/config/clients/python/template/src/exceptions.py.mustache b/config/clients/python/template/src/exceptions.py.mustache index 9f3bb78c..e0f28e0d 100644 --- a/config/clients/python/template/src/exceptions.py.mustache +++ b/config/clients/python/template/src/exceptions.py.mustache @@ -7,7 +7,7 @@ X_RATELIMIT_RESET = "x_ratelimit_reset" FGA_REQUEST_ID = "fga-request-id" FGA_QUERY_DURATION_MS = "fga-query-duration-ms" OPENFGA_AUTHORIZATION_MODEL_ID = "openfga_authorization_model_id" -RETRY_AFTER="retry-after" +RETRY_AFTER = "retry-after" RESPONSE_HEADERS_TO_KEEP = [ X_RATELIMIT_LIMIT, X_RATELIMIT_REMAINING, @@ -15,7 +15,7 @@ RESPONSE_HEADERS_TO_KEEP = [ FGA_REQUEST_ID, FGA_QUERY_DURATION_MS, OPENFGA_AUTHORIZATION_MODEL_ID, - RETRY_AFTER + RETRY_AFTER, ] @@ -24,9 +24,8 @@ class OpenApiException(Exception): class FgaValidationException(OpenApiException, TypeError): - def __init__(self, msg, path_to_item=None, valid_classes=None, - key_type=None): - """ Raises an exception for TypeErrors + def __init__(self, msg, path_to_item=None, valid_classes=None, key_type=None): + """Raises an exception for TypeErrors Args: msg (str): the exception message @@ -107,8 +106,9 @@ class ApiKeyError(OpenApiException, KeyError): class ApiException(OpenApiException): - - def __init__(self, status=None, reason=None, http_resp=None): + def __init__( + self, status=None, reason=None, http_resp=None, *, operation_name=None + ): if http_resp: try: headers = http_resp.headers.items() @@ -130,14 +130,37 @@ class ApiException(OpenApiException): self._parsed_exception = None self.header = dict() + self.operation_name = operation_name + def __str__(self): - """Custom error messages for exception""" - error_message = f"({self.status})\nReason: {self.reason}\n" + """ + Format error with operation context and structured details. + Returns formatted string like: + [write] HTTP 400 type 'invalid_type' not found (validation_error) [request-id: abc-123] + """ + parts = [] + + # Add operation context + if self.operation_name: + parts.append(f"[{self.operation_name}]") + + # Add error type/status + if self.status: + parts.append(f"HTTP {self.status}") + + # Add error message (parsed or reason) + if self.error_message: + parts.append(self.error_message) - if self.body: - error_message += f"HTTP response body: {self.body}\n" + # Add error code in parentheses + if self.code: + parts.append(f"({self.code})") - return error_message + # Add request ID for debugging + if self.request_id: + parts.append(f"[request-id: {self.request_id}]") + + return " ".join(parts) if parts else "Unknown API error" @property def parsed_exception(self): @@ -153,46 +176,165 @@ class ApiException(OpenApiException): """ self._parsed_exception = content + @property + def code(self): + """ + Get the error code from the parsed exception. -class NotFoundException(ApiException): + Returns: + Error code string (e.g., "validation_error") or None + """ + if self._parsed_exception and hasattr(self._parsed_exception, "code"): + code_value = self._parsed_exception.code + # Handle enum types + if hasattr(code_value, "value"): + return code_value.value + return str(code_value) if code_value is not None else None + return None + + @property + def error_message(self): + """ + Get the human-readable error message. - def __init__(self, status=None, reason=None, http_resp=None): - super().__init__(status, reason, http_resp) + Returns: + Error message from API or HTTP reason phrase + """ + if self._parsed_exception and hasattr(self._parsed_exception, "message"): + message = self._parsed_exception.message + if message: + return message + return self.reason or "Unknown error" + @property + def request_id(self): + """ + Get the request ID for debugging and support. -class UnauthorizedException(ApiException): + Returns: + FGA request ID from response headers or None + """ + if not self.header: + return None + # HTTP headers are case-insensitive, try different cases + for key in self.header: + if key.lower() == FGA_REQUEST_ID: + return self.header[key] + return None + + def is_validation_error(self): + """ + Check if this is a validation error. - def __init__(self, status=None, reason=None, http_resp=None): - super().__init__(status, reason, http_resp) + Returns: + True if error code indicates validation failure + """ + return isinstance(self, ValidationException) or ( + self.code and "validation" in self.code.lower() + ) + def is_not_found_error(self): + """ + Check if this is a not found (404) error. -class ForbiddenException(ApiException): + Returns: + True if HTTP status is 404 + """ + return isinstance(self, NotFoundException) or self.status == 404 - def __init__(self, status=None, reason=None, http_resp=None): - super().__init__(status, reason, http_resp) + def is_authentication_error(self): + """ + Check if this is an authentication (401) error. + Returns: + True if HTTP status is 401 + """ + return self.status == 401 -class ServiceException(ApiException): + def is_rate_limit_error(self): + """ + Check if this is a rate limit (429) error. - def __init__(self, status=None, reason=None, http_resp=None): - super().__init__(status, reason, http_resp) + Returns: + True if HTTP status is 429 or error code indicates rate limiting + """ + return self.status == 429 or (self.code and "rate_limit" in self.code.lower()) + def is_retryable(self): + """ + Check if this error should be retried. + + Returns: + True if error is temporary and retrying may succeed + """ + return self.status in [429, 500, 502, 503, 504] if self.status else False + + def is_client_error(self): + """ + Check if this is a client error (4xx). + + Returns: + True if HTTP status is in 400-499 range + """ + return 400 <= self.status < 500 if self.status else False + + def is_server_error(self): + """ + Check if this is a server error (5xx). + + Returns: + True if HTTP status is in 500-599 range + """ + return 500 <= self.status < 600 if self.status else False -class ValidationException(ApiException): - def __init__(self, status=None, reason=None, http_resp=None): - super().__init__(status, reason, http_resp) +class NotFoundException(ApiException): + def __init__( + self, status=None, reason=None, http_resp=None, *, operation_name=None + ): + super().__init__(status, reason, http_resp, operation_name=operation_name) + + +class UnauthorizedException(ApiException): + def __init__( + self, status=None, reason=None, http_resp=None, *, operation_name=None + ): + super().__init__(status, reason, http_resp, operation_name=operation_name) + + +class ForbiddenException(ApiException): + def __init__( + self, status=None, reason=None, http_resp=None, *, operation_name=None + ): + super().__init__(status, reason, http_resp, operation_name=operation_name) + + +class ServiceException(ApiException): + def __init__( + self, status=None, reason=None, http_resp=None, *, operation_name=None + ): + super().__init__(status, reason, http_resp, operation_name=operation_name) + + +class ValidationException(ApiException): + def __init__( + self, status=None, reason=None, http_resp=None, *, operation_name=None + ): + super().__init__(status, reason, http_resp, operation_name=operation_name) class AuthenticationError(ApiException): + def __init__( + self, status=None, reason=None, http_resp=None, *, operation_name=None + ): + super().__init__(status, reason, http_resp, operation_name=operation_name) - def __init__(self, status=None, reason=None, http_resp=None): - super().__init__(status, reason, http_resp) class RateLimitExceededError(ApiException): - - def __init__(self, status=None, reason=None, http_resp=None): - super().__init__(status, reason, http_resp) + def __init__( + self, status=None, reason=None, http_resp=None, *, operation_name=None + ): + super().__init__(status, reason, http_resp, operation_name=operation_name) def render_path(path_to_item): diff --git a/config/clients/python/template/src/sync/api.py.mustache b/config/clients/python/template/src/sync/api.py.mustache index 8437fd47..0e0e606e 100644 --- a/config/clients/python/template/src/sync/api.py.mustache +++ b/config/clients/python/template/src/sync/api.py.mustache @@ -1,5 +1,12 @@ {{>partial_header}} +from __future__ import annotations + +import urllib.parse + +from collections.abc import Iterator +from typing import Any + from {{packageName}}.exceptions import ApiValueError, FgaValidationException from {{packageName}}.sync.api_client import ApiClient from {{packageName}}.sync.oauth2 import OAuth2Client @@ -15,6 +22,24 @@ class {{classname}}: Do not edit the class manually. """ + _COMMON_PARAMS = [ + "async_req", + "_request_timeout", + "_headers", + "_retry_params", + "_streaming", + ] + + _COMMON_ERROR_RESPONSE_TYPES = { + 400: "ValidationErrorMessageResponse", + 401: "UnauthenticatedResponse", + 403: "ForbiddenResponse", + 404: "PathUnknownErrorMessageResponse", + 409: "AbortedMessageResponse", + 422: "UnprocessableContentMessageResponse", + 500: "InternalErrorMessageResponse", + } + def __init__(self, api_client=None): if api_client is None: api_client = ApiClient() @@ -38,9 +63,322 @@ class {{classname}}: def close(self): self.api_client.close() + def _execute( + self, + method: str, + path: str, + operation_name: str, + response_types_map: dict, + body=None, + query_params=None, + headers: dict | None = None, + options: dict | None = None, + ) -> Any: + """Shared executor for all API endpoint methods. + + Delegates to the public execute_api_request / execute_streamed_api_request + so all API calls share one unified code path. + """ + if options is None: + options = {} + + header_params = dict(headers or {}) + header_params["Accept"] = self.api_client.select_header_accept( + ["application/json"] + ) + if body is not None: + content_type = self.api_client.select_header_content_type( + ["application/json"], method, body + ) + if content_type: + header_params["Content-Type"] = content_type + + telemetry_attributes: dict[TelemetryAttribute, str | bool | int | float] = { + TelemetryAttributes.fga_client_request_method: operation_name, + TelemetryAttributes.fga_client_request_store_id: self.api_client.get_store_id(), + TelemetryAttributes.fga_client_request_model_id: options.get( + "authorization_model_id", "" + ), + } + telemetry_attributes = TelemetryAttributes.fromBody( + body=body, attributes=telemetry_attributes + ) + + if options.get("_streaming", False): + return self.execute_streamed_api_request( + operation_name=operation_name, + method=method, + path=path, + body=body, + query_params=query_params or [], + headers=header_params, + options=options, + response_types_map=response_types_map, + telemetry_attributes=telemetry_attributes, + _skip_builder=True, + ) + + return self.execute_api_request( + operation_name=operation_name, + method=method, + path=path, + body=body, + query_params=query_params or [], + headers=header_params, + options=options, + response_types_map=response_types_map, + telemetry_attributes=telemetry_attributes, + _skip_builder=True, + ) + + def execute_api_request( + self, + *, + operation_name: str, + method: str, + path: str, + path_params: dict[str, str] | None = None, + body: dict[str, Any] | list[Any] | str | bytes | None = None, + query_params: dict[str, str | int | list[str | int]] | list | None = None, + headers: dict[str, str] | None = None, + options: dict[str, Any] | None = None, + response_types_map: dict | None = None, + telemetry_attributes: dict | None = None, + _skip_builder: bool = False, + ) -> Any: + """ + Execute an arbitrary HTTP request to any OpenFGA API endpoint. + + Useful for calling endpoints not yet wrapped by the SDK while + still getting authentication, retries, and error handling. + + When called directly by users, returns a RawResponse. + When called internally with response_types_map, returns a deserialized typed model. + + :param operation_name: Operation name for telemetry (e.g., "CustomCheck") + :param method: HTTP method (GET, POST, PUT, DELETE, PATCH) + :param path: API path, e.g. "/stores/{store_id}/my-endpoint". + :param path_params: Path parameter substitutions (URL-encoded automatically). + All path parameters, including store_id, must be provided explicitly. + :param body: Request body for POST/PUT/PATCH + :param query_params: Query string parameters + :param headers: Custom headers (SDK enforces Content-Type and Accept) + :param options: Extra options e.g. {"retry_params": RetryParams(max_retry=3)} + :param response_types_map: When provided, merge with common error types and + return a deserialized typed model. When None, return RawResponse. + :param telemetry_attributes: When provided, use as-is for telemetry. + When None, build minimal defaults. + :param _skip_builder: When True, path/headers/query_params are used as-is + (already resolved by _execute). When False (default), run through + ExecuteApiRequestBuilder. + :return: RawResponse (public callers) or deserialized model (internal callers) + """ + from {{packageName}}.client.execute_api_request_builder import ( + ExecuteApiRequestBuilder, + ResponseParser, + ) + from {{packageName}}.client.models.raw_response import RawResponse + + if _skip_builder: + resource_path = path + query_params_list = query_params if query_params is not None else [] + final_headers = headers or {} + else: + builder = ExecuteApiRequestBuilder( + operation_name=operation_name, + method=method, + path=path, + path_params=path_params, + body=body, + query_params=query_params, + headers=headers, + ) + builder.validate() + + resource_path = builder.build_path() + query_params_list = builder.build_query_params_list() + final_headers = builder.build_headers() + + retry_params = ( + options.get("retry_params") or options.get("_retry_params") + if options + else None + ) + request_timeout = options.get("_request_timeout") if options else None + async_req = options.get("async_req") if options else None + + if telemetry_attributes is None: + telemetry_attributes = { + TelemetryAttributes.fga_client_request_method: operation_name.lower(), + } + if self.api_client.get_store_id(): + telemetry_attributes[ + TelemetryAttributes.fga_client_request_store_id + ] = self.api_client.get_store_id() + + if response_types_map is not None: + merged_response_types_map = { + **self._COMMON_ERROR_RESPONSE_TYPES, + **response_types_map, + } + return self.api_client.call_api( + resource_path=resource_path, + method=method.upper(), + path_params={}, + query_params=query_params_list if query_params_list else [], + header_params=final_headers, + body=body, + post_params=[], + files={}, + response_types_map=merged_response_types_map, + auth_settings=[], + async_req=async_req, + _return_http_data_only=True, + _preload_content=True, + _request_timeout=request_timeout, + _retry_params=retry_params, + collection_formats={}, + _oauth2_client=self._oauth2_client, + _telemetry_attributes=telemetry_attributes, + _streaming=False, + ) + + # No response_types_map: public caller path → return RawResponse + self.api_client.call_api( + resource_path=resource_path, + method=method.upper(), + query_params=query_params_list if query_params_list else None, + header_params=final_headers, + body=body, + response_types_map={}, + auth_settings=[], + _return_http_data_only=True, + _preload_content=True, + _retry_params=retry_params, + _oauth2_client=self._oauth2_client, + _telemetry_attributes=telemetry_attributes, + _streaming=False, + ) + + rest_response = getattr(self.api_client, "last_response", None) + if rest_response is None: + raise RuntimeError( + f"No response for {method.upper()} {resource_path} " + f"(operation: {operation_name})" + ) + + return RawResponse( + status=rest_response.status, + headers=dict(rest_response.getheaders()), + body=ResponseParser.parse_body(rest_response.data), + ) + + def execute_streamed_api_request( + self, + *, + operation_name: str, + method: str, + path: str, + path_params: dict[str, str] | None = None, + body: dict[str, Any] | list[Any] | str | bytes | None = None, + query_params: dict[str, str | int | list[str | int]] | list | None = None, + headers: dict[str, str] | None = None, + options: dict[str, Any] | None = None, + response_types_map: dict | None = None, + telemetry_attributes: dict | None = None, + _skip_builder: bool = False, + ) -> Iterator[dict[str, Any]]: + """ + Execute an arbitrary HTTP request to a streaming OpenFGA API endpoint. + + Yields parsed JSON objects as they arrive. Use with for: + + for chunk in api.execute_streamed_api_request(...): + process(chunk) + + :param operation_name: Operation name for telemetry (e.g., "StreamedListObjects") + :param method: HTTP method (GET, POST, PUT, DELETE, PATCH) + :param path: API path, e.g. "/stores/{store_id}/streamed-list-objects". + :param path_params: Path parameter substitutions (URL-encoded automatically). + All path parameters, including store_id, must be provided explicitly. + :param body: Request body for POST/PUT/PATCH + :param query_params: Query string parameters + :param headers: Custom headers (SDK enforces Content-Type and Accept) + :param options: Extra options e.g. {"retry_params": RetryParams(max_retry=3)} + :param response_types_map: When provided, merge with common error types. + :param telemetry_attributes: When provided, use as-is for telemetry. + :param _skip_builder: When True, path/headers/query_params are used as-is. + """ + if _skip_builder: + resource_path = path + query_params_list = query_params if query_params is not None else [] + final_headers = headers or {} + else: + from {{packageName}}.client.execute_api_request_builder import ( + ExecuteApiRequestBuilder, + ) + + builder = ExecuteApiRequestBuilder( + operation_name=operation_name, + method=method, + path=path, + path_params=path_params, + body=body, + query_params=query_params, + headers=headers, + ) + builder.validate() + + resource_path = builder.build_path() + query_params_list = builder.build_query_params_list() + final_headers = builder.build_headers() + + retry_params = ( + options.get("retry_params") or options.get("_retry_params") + if options + else None + ) + request_timeout = options.get("_request_timeout") if options else None + + if telemetry_attributes is None: + telemetry_attributes = { + TelemetryAttributes.fga_client_request_method: operation_name.lower(), + } + if self.api_client.get_store_id(): + telemetry_attributes[ + TelemetryAttributes.fga_client_request_store_id + ] = self.api_client.get_store_id() + + if response_types_map is not None: + merged_response_types_map = { + **self._COMMON_ERROR_RESPONSE_TYPES, + **response_types_map, + } + else: + merged_response_types_map = {} + + stream = self.api_client.call_api( + resource_path=resource_path, + method=method.upper(), + query_params=query_params_list if query_params_list else None, + header_params=final_headers, + body=body, + response_types_map=merged_response_types_map, + auth_settings=[], + _return_http_data_only=True, + _preload_content=True, + _request_timeout=request_timeout, + _retry_params=retry_params, + _oauth2_client=self._oauth2_client, + _telemetry_attributes=telemetry_attributes, + _streaming=True, + ) + + yield from stream + {{#operation}} - def {{operationId}}(self, {{#sortParamsByRequiredFlag}}{{#allParams}}{{#required}}{{^-first}}{{paramName}}, {{/-first}}{{/required}}{{/allParams}}{{/sortParamsByRequiredFlag}}**kwargs): + def {{operationId}}(self, {{#sortParamsByRequiredFlag}}{{#allParams}}{{#required}}{{#isPathParam}}{{^-first}}{{paramName}}, {{/-first}}{{/isPathParam}}{{^isPathParam}}{{paramName}}, {{/isPathParam}}{{/required}}{{/allParams}}{{/sortParamsByRequiredFlag}}**kwargs): """{{{summary}}}{{^summary}}{{operationId}}{{/summary}} {{#notes}} @@ -48,17 +386,20 @@ class {{classname}}: {{/notes}} {{#sortParamsByRequiredFlag}} - >>> thread = api.{{operationId}}({{#allParams}}{{#required}}{{^-first}}{{paramName}}{{^-last}}, {{/-last}}{{/-first}}{{/required}}{{/allParams}}) -{{/sortParamsByRequiredFlag}} -{{^sortParamsByRequiredFlag}} - >>> thread = api.{{operationId}}({{#allParams}}{{#required}}{{^-first}}{{paramName}}={{paramName}}_value{{^-last}}, {{/-last}} {{/-first}}{{/required}}{{/allParams}}) + >>> thread = api.{{operationId}}({{#allParams}}{{#required}}{{#isPathParam}}{{^-first}}{{paramName}}{{^-last}}, {{/-last}}{{/-first}}{{/isPathParam}}{{^isPathParam}}{{paramName}}{{^-last}}, {{/-last}}{{/isPathParam}}{{/required}}{{/allParams}}) {{/sortParamsByRequiredFlag}} {{#requiredParams}} +{{#isPathParam}} {{^-first}} :param {{paramName}}:{{#description}} {{{.}}}{{/description}} (required) :type {{paramName}}: {{dataType}} {{/-first}} +{{/isPathParam}} +{{^isPathParam}} + :param {{paramName}}:{{#description}} {{{.}}}{{/description}} (required) + :type {{paramName}}: {{dataType}} +{{/isPathParam}} {{/requiredParams}} {{#optionalParams}} :param {{paramName}}:{{#description}} {{{.}}}{{/description}}(optional) @@ -79,10 +420,9 @@ class {{classname}}: returns the request thread. :rtype: {{returnType}}{{^returnType}}None{{/returnType}} """ - kwargs["_return_http_data_only"] = True - return self.{{operationId}}_with_http_info({{#sortParamsByRequiredFlag}}{{#allParams}}{{#required}}{{^-first}}{{paramName}}, {{/-first}}{{/required}}{{/allParams}}{{/sortParamsByRequiredFlag}}**kwargs) + return self.{{operationId}}_with_http_info({{#sortParamsByRequiredFlag}}{{#allParams}}{{#required}}{{#isPathParam}}{{^-first}}{{paramName}}, {{/-first}}{{/isPathParam}}{{^isPathParam}}{{paramName}}, {{/isPathParam}}{{/required}}{{/allParams}}{{/sortParamsByRequiredFlag}}**kwargs) - def {{operationId}}_with_http_info(self, {{#sortParamsByRequiredFlag}}{{#allParams}}{{#required}}{{^-first}}{{paramName}}, {{/-first}}{{/required}}{{/allParams}}{{/sortParamsByRequiredFlag}}**kwargs): + def {{operationId}}_with_http_info(self, {{#sortParamsByRequiredFlag}}{{#allParams}}{{#required}}{{#isPathParam}}{{^-first}}{{paramName}}, {{/-first}}{{/isPathParam}}{{^isPathParam}}{{paramName}}, {{/isPathParam}}{{/required}}{{/allParams}}{{/sortParamsByRequiredFlag}}**kwargs): """{{{summary}}}{{^summary}}{{operationId}}{{/summary}} {{#notes}} @@ -90,17 +430,20 @@ class {{classname}}: {{/notes}} {{#sortParamsByRequiredFlag}} - >>> thread = api.{{operationId}}_with_http_info({{#allParams}}{{#required}}{{^-first}}{{paramName}}{{^-last}}, {{/-last}}{{/-first}}{{/required}}{{/allParams}}) -{{/sortParamsByRequiredFlag}} -{{^sortParamsByRequiredFlag}} - >>> thread = api.{{operationId}}_with_http_info({{#allParams}}{{#required}}{{^-first}}{{paramName}}={{paramName}}_value{{^-last}}, {{/-last}} {{/-first}}{{/required}}{{/allParams}}) + >>> thread = api.{{operationId}}_with_http_info({{#allParams}}{{#required}}{{#isPathParam}}{{^-first}}{{paramName}}{{^-last}}, {{/-last}}{{/-first}}{{/isPathParam}}{{^isPathParam}}{{paramName}}{{^-last}}, {{/-last}}{{/isPathParam}}{{/required}}{{/allParams}}) {{/sortParamsByRequiredFlag}} {{#requiredParams}} +{{#isPathParam}} {{^-first}} :param {{paramName}}:{{#description}} {{{.}}}{{/description}} (required) :type {{paramName}}: {{dataType}} {{/-first}} +{{/isPathParam}} +{{^isPathParam}} + :param {{paramName}}:{{#description}} {{{.}}}{{/description}} (required) + :type {{paramName}}: {{dataType}} +{{/isPathParam}} {{/requiredParams}} {{#optionalParams}} :param {{paramName}}:{{#description}} {{{.}}}{{/description}}(optional) @@ -131,187 +474,97 @@ class {{classname}}: :rtype: {{#returnType}}tuple({{.}}, status_code(int), headers(HTTPHeaderDict)){{/returnType}}{{^returnType}}None{{/returnType}} """ - {{#servers.0}} - local_var_hosts = [ -{{#servers}} - '{{{url}}}'{{^-last}},{{/-last}} -{{/servers}} - ] - local_var_host = local_var_hosts[0] - if kwargs.get('_host_index'): - _host_index = int(kwargs.get('_host_index')) - if _host_index < 0 or _host_index >= len(local_var_hosts): - raise ApiValueError( - "Invalid host index. Must be 0 <= index < %s" - % len(local_var_host) - ) - local_var_host = local_var_hosts[_host_index] - {{/servers.0}} local_var_params = locals() all_params = [ -{{#requiredParams}}{{^-first}} - '{{paramName}}'{{^-last}},{{/-last}} -{{/-first}}{{/requiredParams}} +{{#requiredParams}} +{{#isPathParam}} +{{^-first}} + "{{paramName}}", +{{/-first}} +{{/isPathParam}} +{{^isPathParam}} + "{{paramName}}", +{{/isPathParam}} +{{/requiredParams}} {{#optionalParams}} - '{{paramName}}'{{^-last}},{{/-last}} + "{{paramName}}", {{/optionalParams}} ] - all_params.extend( - [ - "async_req", - "_return_http_data_only", - "_preload_content", - "_request_timeout", - "_request_auth", - "_content_type", - "_headers", - "_retry_params", - "_streaming", - ] - ) + all_params.extend(self._COMMON_PARAMS) - for key, val in local_var_params['kwargs'].items(): - if key not in all_params{{#servers.0}} and key != "_host_index"{{/servers.0}}: + for key, val in local_var_params["kwargs"].items(): + if key not in all_params: raise FgaValidationException( f"Got an unexpected keyword argument '{key}' to method {{operationId}}" ) local_var_params[key] = val - del local_var_params['kwargs'] + del local_var_params["kwargs"] {{#allParams}} {{^isNullable}} {{#required}} +{{#isPathParam}} {{^-first}} - # verify the required parameter '{{paramName}}' is set - if self.api_client.client_side_validation and local_var_params.get('{{paramName}}') is None: + if ( + self.api_client.client_side_validation + and local_var_params.get("{{paramName}}") is None + ): raise ApiValueError( - "Missing the required parameter `{{paramName}}` when calling `{{operationId}}`") + "Missing the required parameter `{{paramName}}` when calling `{{operationId}}`" + ) {{/-first}} +{{/isPathParam}} +{{^isPathParam}} + # verify the required parameter '{{paramName}}' is set + if ( + self.api_client.client_side_validation + and local_var_params.get("{{paramName}}") is None + ): + raise ApiValueError( + "Missing the required parameter `{{paramName}}` when calling `{{operationId}}`" + ) +{{/isPathParam}} {{/required}} {{/isNullable}} {{/allParams}} - -{{#allParams}} -{{#-last}} -{{/-last}} -{{/allParams}} - collection_formats = {} - - path_params = {} - - store_id = None {{#pathParams}} -{{^-first}} - if '{{paramName}}' in local_var_params: - path_params['{{baseName}}'] = local_var_params['{{paramName}}']{{#isArray}} - collection_formats['{{baseName}}'] = '{{collectionFormat}}'{{/isArray}} -{{/-first}} - {{#-first}} if self.api_client._get_store_id() is None: - raise ApiValueError("Store ID expected in api_client's configuration when calling `{{operationId}}`") + raise ApiValueError( + "Store ID expected in api_client's configuration when calling `{{operationId}}`" + ) store_id = self.api_client._get_store_id() {{/-first}} - {{/pathParams}} - - query_params = [] {{#queryParams}} - if local_var_params.get('{{paramName}}') is not None: - query_params.append( - ('{{baseName}}', local_var_params['{{paramName}}'])){{#isArray}} - collection_formats['{{baseName}}'] = '{{collectionFormat}}'{{/isArray}} +{{#-first}} + query_params = [] +{{/-first}} + if local_var_params.get("{{paramName}}") is not None: + query_params.append(("{{baseName}}", local_var_params["{{paramName}}"])) {{/queryParams}} - - header_params = dict(local_var_params.get('_headers', {})) -{{#headerParams}} - if '{{paramName}}' in local_var_params: - header_params['{{baseName}}'] = local_var_params['{{paramName}}']{{#isArray}} - collection_formats['{{baseName}}'] = '{{collectionFormat}}'{{/isArray}} -{{/headerParams}} - - form_params = [] - local_var_files = {} -{{#formParams}} - if '{{paramName}}' in local_var_params: - {{^isFile}}form_params.append(('{{baseName}}', local_var_params['{{paramName}}'])){{/isFile}}{{#isFile}}local_var_files['{{baseName}}'] = local_var_params['{{paramName}}']{{/isFile}}{{#isArray}} - collection_formats['{{baseName}}'] = '{{collectionFormat}}'{{/isArray}} -{{/formParams}} - - body_params = None + return self._execute( + method="{{httpMethod}}", + path={{#pathParams}}{{#-first}}f"{{/-first}}{{/pathParams}}{{^pathParams}}"{{/pathParams}}{{{path}}}", + operation_name="{{operationId}}", +{{#returnType}} +{{#responses}}{{#is2xx}} + response_types_map={ {{code}}: {{#dataType}}"{{.}}"{{/dataType}}{{^dataType}}None{{/dataType}}}, +{{/is2xx}}{{/responses}} +{{/returnType}} +{{^returnType}} + response_types_map={}, +{{/returnType}} {{#bodyParam}} - if '{{paramName}}' in local_var_params: - body_params = local_var_params['{{paramName}}'] + body=local_var_params.get("{{paramName}}"), {{/bodyParam}} - {{#hasProduces}} - # HTTP header `Accept` - header_params['Accept'] = self.api_client.select_header_accept( - [{{#produces}}'{{{mediaType}}}'{{^-last}}, {{/-last}}{{/produces}}]) - - {{/hasProduces}} - {{#hasConsumes}} - # HTTP header `Content-Type` - content_types_list = local_var_params.get('_content_type', self.api_client.select_header_content_type([{{#consumes}}'{{{mediaType}}}'{{^-last}}, {{/-last}}{{/consumes}}],'{{httpMethod}}', body_params)) - if content_types_list: - header_params['Content-Type'] = content_types_list - - {{/hasConsumes}} - # Authentication setting - auth_settings = [{{#authMethods}}'{{name}}'{{^-last}}, {{/-last}}{{/authMethods}}] - - {{#returnType}} - {{#responses}} - {{#-first}} - response_types_map = { - {{/-first}} - {{^isWildcard}} - {{code}}: {{#dataType}}"{{.}}"{{/dataType}}{{^dataType}}None{{/dataType}}, - {{/isWildcard}} - {{#-last}} - } - {{/-last}} - {{/responses}} - {{/returnType}} - {{^returnType}} - response_types_map = {} - {{/returnType}} - - telemetry_attributes: dict[TelemetryAttribute, str | bool | int | float] = { - TelemetryAttributes.fga_client_request_method: "{{operationId}}", - 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( - '{{{path}}}'.replace('{store_id}', store_id), '{{httpMethod}}', - 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'), - {{#servers.0}} - _host=local_var_host, - {{/servers.0}} - 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), +{{#queryParams}} +{{#-first}} + query_params=query_params, +{{/-first}} +{{/queryParams}} + headers=local_var_params.get("_headers"), + options=local_var_params, ) {{/operation}} {{/operations}} diff --git a/config/common/files/README.mustache b/config/common/files/README.mustache index 06db0f2c..b23afdfb 100644 --- a/config/common/files/README.mustache +++ b/config/common/files/README.mustache @@ -49,6 +49,9 @@ - [Read Assertions](#read-assertions) - [Write Assertions](#write-assertions) - [Retries](#retries) +{{#supportsCallingOtherEndpoints}} + - [Calling Other Endpoints](#calling-other-endpoints) +{{/supportsCallingOtherEndpoints}} - [API Endpoints](#api-endpoints) - [Models](#models) {{#openTelemetryDocumentation}}