diff --git a/src/workos/async_client.py b/src/workos/async_client.py index d2b3921c..84d00b75 100644 --- a/src/workos/async_client.py +++ b/src/workos/async_client.py @@ -7,6 +7,7 @@ from workos.connect import AsyncConnect from workos.directory_sync import AsyncDirectorySync from workos.events import AsyncEvents +from workos.feature_flags import AsyncFeatureFlags from workos.fga import FGAModule from workos.mfa import MFAModule from workos.organizations import AsyncOrganizations @@ -95,6 +96,12 @@ def events(self) -> AsyncEvents: self._events = AsyncEvents(self._http_client) return self._events + @property + def feature_flags(self) -> AsyncFeatureFlags: + if not getattr(self, "_feature_flags", None): + self._feature_flags = AsyncFeatureFlags(self._http_client) + return self._feature_flags + @property def fga(self) -> FGAModule: raise NotImplementedError("FGA APIs are not yet supported in the async client.") diff --git a/src/workos/client.py b/src/workos/client.py index 7e942924..375b2d02 100644 --- a/src/workos/client.py +++ b/src/workos/client.py @@ -6,6 +6,7 @@ from workos.authorization import Authorization from workos.connect import Connect from workos.directory_sync import DirectorySync +from workos.feature_flags import FeatureFlags from workos.fga import FGA from workos.organizations import Organizations from workos.organization_domains import OrganizationDomains @@ -93,6 +94,12 @@ def events(self) -> Events: self._events = Events(self._http_client) return self._events + @property + def feature_flags(self) -> FeatureFlags: + if not getattr(self, "_feature_flags", None): + self._feature_flags = FeatureFlags(self._http_client) + return self._feature_flags + @property def fga(self) -> FGA: if not getattr(self, "_fga", None): diff --git a/src/workos/feature_flags.py b/src/workos/feature_flags.py new file mode 100644 index 00000000..89ab28f3 --- /dev/null +++ b/src/workos/feature_flags.py @@ -0,0 +1,251 @@ +from typing import Optional, Protocol + +from workos.types.feature_flags import FeatureFlag +from workos.types.feature_flags.list_filters import FeatureFlagListFilters +from workos.types.list_resource import ListMetadata, ListPage, WorkOSListResource +from workos.typing.sync_or_async import SyncOrAsync +from workos.utils.http_client import AsyncHTTPClient, SyncHTTPClient +from workos.utils.pagination_order import PaginationOrder +from workos.utils.request_helper import ( + DEFAULT_LIST_RESPONSE_LIMIT, + REQUEST_METHOD_DELETE, + REQUEST_METHOD_GET, + REQUEST_METHOD_POST, + REQUEST_METHOD_PUT, +) + +FEATURE_FLAGS_PATH = "feature-flags" + +FeatureFlagsListResource = WorkOSListResource[ + FeatureFlag, FeatureFlagListFilters, ListMetadata +] + + +class FeatureFlagsModule(Protocol): + """Offers methods through the WorkOS Feature Flags service.""" + + def list_feature_flags( + self, + *, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> SyncOrAsync[FeatureFlagsListResource]: + """Retrieve a list of feature flags. + + Kwargs: + limit (int): Maximum number of records to return. (Optional) + before (str): Pagination cursor to receive records before a provided ID. (Optional) + after (str): Pagination cursor to receive records after a provided ID. (Optional) + order (Literal["asc","desc"]): Sort records in either ascending or descending order. (Optional) + + Returns: + FeatureFlagsListResource: Feature flags list response from WorkOS. + """ + ... + + def get_feature_flag(self, slug: str) -> SyncOrAsync[FeatureFlag]: + """Gets details for a single feature flag. + + Args: + slug (str): The unique slug identifier of the feature flag. + + Returns: + FeatureFlag: Feature flag response from WorkOS. + """ + ... + + def enable_feature_flag(self, slug: str) -> SyncOrAsync[FeatureFlag]: + """Enable a feature flag. + + Args: + slug (str): The unique slug identifier of the feature flag. + + Returns: + FeatureFlag: Updated feature flag response from WorkOS. + """ + ... + + def disable_feature_flag(self, slug: str) -> SyncOrAsync[FeatureFlag]: + """Disable a feature flag. + + Args: + slug (str): The unique slug identifier of the feature flag. + + Returns: + FeatureFlag: Updated feature flag response from WorkOS. + """ + ... + + def add_feature_flag_target(self, slug: str, resource_id: str) -> SyncOrAsync[None]: + """Add a target to a feature flag. + + Args: + slug (str): The unique slug identifier of the feature flag. + resource_id (str): Resource ID in format user_ or org_. + + Returns: + None + """ + ... + + def remove_feature_flag_target( + self, slug: str, resource_id: str + ) -> SyncOrAsync[None]: + """Remove a target from a feature flag. + + Args: + slug (str): The unique slug identifier of the feature flag. + resource_id (str): Resource ID in format user_ or org_. + + Returns: + None + """ + ... + + +class FeatureFlags(FeatureFlagsModule): + _http_client: SyncHTTPClient + + def __init__(self, http_client: SyncHTTPClient): + self._http_client = http_client + + def list_feature_flags( + self, + *, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> FeatureFlagsListResource: + list_params: FeatureFlagListFilters = { + "limit": limit, + "before": before, + "after": after, + "order": order, + } + + response = self._http_client.request( + FEATURE_FLAGS_PATH, + method=REQUEST_METHOD_GET, + params=list_params, + ) + + return WorkOSListResource[FeatureFlag, FeatureFlagListFilters, ListMetadata]( + list_method=self.list_feature_flags, + list_args=list_params, + **ListPage[FeatureFlag](**response).model_dump(), + ) + + def get_feature_flag(self, slug: str) -> FeatureFlag: + response = self._http_client.request( + f"{FEATURE_FLAGS_PATH}/{slug}", + method=REQUEST_METHOD_GET, + ) + + return FeatureFlag.model_validate(response) + + def enable_feature_flag(self, slug: str) -> FeatureFlag: + response = self._http_client.request( + f"{FEATURE_FLAGS_PATH}/{slug}/enable", + method=REQUEST_METHOD_PUT, + json={}, + ) + + return FeatureFlag.model_validate(response) + + def disable_feature_flag(self, slug: str) -> FeatureFlag: + response = self._http_client.request( + f"{FEATURE_FLAGS_PATH}/{slug}/disable", + method=REQUEST_METHOD_PUT, + json={}, + ) + + return FeatureFlag.model_validate(response) + + def add_feature_flag_target(self, slug: str, resource_id: str) -> None: + self._http_client.request( + f"{FEATURE_FLAGS_PATH}/{slug}/targets/{resource_id}", + method=REQUEST_METHOD_POST, + json={}, + ) + + def remove_feature_flag_target(self, slug: str, resource_id: str) -> None: + self._http_client.request( + f"{FEATURE_FLAGS_PATH}/{slug}/targets/{resource_id}", + method=REQUEST_METHOD_DELETE, + ) + + +class AsyncFeatureFlags(FeatureFlagsModule): + _http_client: AsyncHTTPClient + + def __init__(self, http_client: AsyncHTTPClient): + self._http_client = http_client + + async def list_feature_flags( + self, + *, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> FeatureFlagsListResource: + list_params: FeatureFlagListFilters = { + "limit": limit, + "before": before, + "after": after, + "order": order, + } + + response = await self._http_client.request( + FEATURE_FLAGS_PATH, + method=REQUEST_METHOD_GET, + params=list_params, + ) + + return WorkOSListResource[FeatureFlag, FeatureFlagListFilters, ListMetadata]( + list_method=self.list_feature_flags, + list_args=list_params, + **ListPage[FeatureFlag](**response).model_dump(), + ) + + async def get_feature_flag(self, slug: str) -> FeatureFlag: + response = await self._http_client.request( + f"{FEATURE_FLAGS_PATH}/{slug}", + method=REQUEST_METHOD_GET, + ) + + return FeatureFlag.model_validate(response) + + async def enable_feature_flag(self, slug: str) -> FeatureFlag: + response = await self._http_client.request( + f"{FEATURE_FLAGS_PATH}/{slug}/enable", + method=REQUEST_METHOD_PUT, + json={}, + ) + + return FeatureFlag.model_validate(response) + + async def disable_feature_flag(self, slug: str) -> FeatureFlag: + response = await self._http_client.request( + f"{FEATURE_FLAGS_PATH}/{slug}/disable", + method=REQUEST_METHOD_PUT, + json={}, + ) + + return FeatureFlag.model_validate(response) + + async def add_feature_flag_target(self, slug: str, resource_id: str) -> None: + await self._http_client.request( + f"{FEATURE_FLAGS_PATH}/{slug}/targets/{resource_id}", + method=REQUEST_METHOD_POST, + json={}, + ) + + async def remove_feature_flag_target(self, slug: str, resource_id: str) -> None: + await self._http_client.request( + f"{FEATURE_FLAGS_PATH}/{slug}/targets/{resource_id}", + method=REQUEST_METHOD_DELETE, + ) diff --git a/src/workos/organizations.py b/src/workos/organizations.py index 2cf6276b..7ffaadf5 100644 --- a/src/workos/organizations.py +++ b/src/workos/organizations.py @@ -3,6 +3,7 @@ from workos.types.api_keys import ApiKey, ApiKeyWithValue from workos.types.api_keys.list_filters import ApiKeyListFilters +from workos.feature_flags import FeatureFlagsListResource from workos.types.feature_flags import FeatureFlag from workos.types.feature_flags.list_filters import FeatureFlagListFilters from workos.types.metadata import Metadata @@ -29,10 +30,6 @@ Organization, OrganizationListFilters, ListMetadata ] -FeatureFlagsListResource = WorkOSListResource[ - FeatureFlag, FeatureFlagListFilters, ListMetadata -] - ApiKeysListResource = WorkOSListResource[ApiKey, ApiKeyListFilters, ListMetadata] diff --git a/src/workos/types/feature_flags/__init__.py b/src/workos/types/feature_flags/__init__.py index 0547a748..64da9cde 100644 --- a/src/workos/types/feature_flags/__init__.py +++ b/src/workos/types/feature_flags/__init__.py @@ -1,3 +1,3 @@ -from workos.types.feature_flags.feature_flag import FeatureFlag +from workos.types.feature_flags.feature_flag import FeatureFlag, FeatureFlagOwner -__all__ = ["FeatureFlag"] +__all__ = ["FeatureFlag", "FeatureFlagOwner"] diff --git a/src/workos/types/feature_flags/feature_flag.py b/src/workos/types/feature_flags/feature_flag.py index b634539f..a328242c 100644 --- a/src/workos/types/feature_flags/feature_flag.py +++ b/src/workos/types/feature_flags/feature_flag.py @@ -1,12 +1,22 @@ -from typing import Literal, Optional +from typing import Literal, Optional, Sequence from workos.types.workos_model import WorkOSModel +class FeatureFlagOwner(WorkOSModel): + email: str + first_name: Optional[str] + last_name: Optional[str] + + class FeatureFlag(WorkOSModel): id: str object: Literal["feature_flag"] slug: str name: str description: Optional[str] + tags: Sequence[str] + owner: Optional[FeatureFlagOwner] + enabled: bool + default_value: bool created_at: str updated_at: str diff --git a/src/workos/user_management.py b/src/workos/user_management.py index b8a72d62..24dfc403 100644 --- a/src/workos/user_management.py +++ b/src/workos/user_management.py @@ -3,6 +3,7 @@ from workos._client_configuration import ClientConfiguration from workos.session import AsyncSession, Session +from workos.feature_flags import FeatureFlagsListResource from workos.types.feature_flags import FeatureFlag from workos.types.feature_flags.list_filters import FeatureFlagListFilters from workos.types.list_resource import ( @@ -119,10 +120,6 @@ Invitation, InvitationsListFilters, ListMetadata ] -FeatureFlagsListResource = WorkOSListResource[ - FeatureFlag, FeatureFlagListFilters, ListMetadata -] - SessionsListResource = WorkOSListResource[ UserManagementSession, SessionsListFilters, ListMetadata ] diff --git a/tests/test_feature_flags.py b/tests/test_feature_flags.py new file mode 100644 index 00000000..a9c55bbb --- /dev/null +++ b/tests/test_feature_flags.py @@ -0,0 +1,119 @@ +from typing import Union +import pytest +from tests.utils.fixtures.mock_feature_flag import MockFeatureFlag +from tests.utils.syncify import syncify +from workos.feature_flags import AsyncFeatureFlags, FeatureFlags + + +@pytest.mark.sync_and_async(FeatureFlags, AsyncFeatureFlags) +class TestFeatureFlags: + @pytest.fixture(autouse=True) + def setup(self, module_instance: Union[FeatureFlags, AsyncFeatureFlags]): + self.http_client = module_instance._http_client + self.feature_flags = module_instance + + @pytest.fixture + def mock_feature_flag(self): + return MockFeatureFlag("flag_01").dict() + + @pytest.fixture + def mock_feature_flag_enabled(self): + return MockFeatureFlag("flag_01", enabled=True).dict() + + @pytest.fixture + def mock_feature_flag_disabled(self): + return MockFeatureFlag("flag_01", enabled=False).dict() + + @pytest.fixture + def mock_feature_flags_list(self): + return { + "data": [MockFeatureFlag(id=f"flag_{i}").dict() for i in range(3)], + "object": "list", + "list_metadata": {"before": None, "after": None}, + } + + def test_list_feature_flags( + self, mock_feature_flags_list, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_feature_flags_list, 200 + ) + + result = syncify(self.feature_flags.list_feature_flags()) + + def to_dict(x): + return x.dict() + + assert request_kwargs["method"] == "get" + assert request_kwargs["url"].endswith("/feature-flags") + assert list(map(to_dict, result.data)) == mock_feature_flags_list["data"] + + def test_get_feature_flag( + self, mock_feature_flag, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_feature_flag, 200 + ) + + result = syncify(self.feature_flags.get_feature_flag("test-feature")) + + assert request_kwargs["method"] == "get" + assert request_kwargs["url"].endswith("/feature-flags/test-feature") + assert result.slug == "test-feature" + assert result.id == "flag_01" + + def test_enable_feature_flag( + self, mock_feature_flag_enabled, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_feature_flag_enabled, 200 + ) + + result = syncify(self.feature_flags.enable_feature_flag("test-feature")) + + assert request_kwargs["method"] == "put" + assert request_kwargs["url"].endswith("/feature-flags/test-feature/enable") + assert result.enabled is True + + def test_disable_feature_flag( + self, mock_feature_flag_disabled, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_feature_flag_disabled, 200 + ) + + result = syncify(self.feature_flags.disable_feature_flag("test-feature")) + + assert request_kwargs["method"] == "put" + assert request_kwargs["url"].endswith("/feature-flags/test-feature/disable") + assert result.enabled is False + + def test_add_feature_flag_target(self, capture_and_mock_http_client_request): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, None, 200 + ) + + result = syncify( + self.feature_flags.add_feature_flag_target("test-feature", "org_01ABC") + ) + + assert request_kwargs["method"] == "post" + assert request_kwargs["url"].endswith( + "/feature-flags/test-feature/targets/org_01ABC" + ) + assert result is None + + def test_remove_feature_flag_target(self, capture_and_mock_http_client_request): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, None, 200 + ) + + result = syncify( + self.feature_flags.remove_feature_flag_target("test-feature", "user_01XYZ") + ) + + assert request_kwargs["method"] == "delete" + assert request_kwargs["url"].endswith( + "/feature-flags/test-feature/targets/user_01XYZ" + ) + assert result is None diff --git a/tests/utils/fixtures/mock_feature_flag.py b/tests/utils/fixtures/mock_feature_flag.py index 1100e816..df677a33 100644 --- a/tests/utils/fixtures/mock_feature_flag.py +++ b/tests/utils/fixtures/mock_feature_flag.py @@ -4,7 +4,7 @@ class MockFeatureFlag(FeatureFlag): - def __init__(self, id): + def __init__(self, id, enabled=True): now = datetime.datetime.now().isoformat() super().__init__( object="feature_flag", @@ -12,6 +12,14 @@ def __init__(self, id): slug="test-feature", name="Test Feature", description="A test feature flag", + tags=["test"], + owner={ + "email": "admin@example.com", + "first_name": "Test", + "last_name": "User", + }, + enabled=enabled, + default_value=False, created_at=now, updated_at=now, )