From 6810556b2fffeffceab2f56121ed925706572c05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B4natas=20Santos?= Date: Mon, 22 Jul 2024 18:44:07 -0300 Subject: [PATCH 01/18] Refactor and streamline organization and set base for SDK typing (#284) * Refactor and streamline organization tests and resources Removed deprecated organization methods and updated organization tests. Also, migrated organization.py to Pydantic models, and added mypy typing for more strict type checking. Additionally, enhance auto-paging to work with new models and restructured fixtures for organization tests. * Switch directories over to new list resources and add typing * Mark version as alpha * Formatting and some type fixes * Add defaults for all optional fields * Type dsync getters * Possible path forward for unknown literal values * Move comments * Export the untyped type and a type guard. Leave a comment about my thoughts on the approach * Test out literals of strings vs enums and factor out types and helpers to a typing directory * Settled on an approach for untyped literals * A little refactoring * More accurate validation for UntypedLiteral * Comments reminding us to clean up * Add typing to the rest of the dsync methods * Just some formattin' * A little more cleanup to unify organizations and dsync * Fix organizations test * DirectorySync tests passing, but still need some cleanup * Do not bump version yet * Formatting * Clean up by removing the enum paths * Small fixed for compat * More formatting and compat * Start fixing dsync tests * Fix another dsync test * Fix more dsync tests * Fix params, optional allows None, instead can force default * Directory sync tests are green * Fix audit logs test * Fix organizations test * Fix auto paging iter tests * Remove debugging print * Fix user management tests * Switch override to typing_extensions * Build fixes. Proper dict override and remove duplicate model key * Add directory sync resource to mypy.ini * Pantera can't spell * Fix type and delete unused fixtures * Remove unused import * Update dsync tests to new pagination helper * Delete unused fixtures * Remove some unneeded comments * PR feedback * Remove trailing comma in mypy.ini --------- Co-authored-by: Peter Arzhintar --- .github/workflows/ci.yml | 4 + mypy.ini | 2 + setup.py | 7 +- tests/conftest.py | 46 +- tests/test_directory_sync.py | 512 ++++--------------- tests/test_organizations.py | 322 +++--------- tests/test_user_management.py | 195 ++++--- tests/utils/fixtures/mock_directory.py | 10 +- tests/utils/fixtures/mock_directory_group.py | 9 +- tests/utils/fixtures/mock_directory_user.py | 13 +- tests/utils/fixtures/mock_organization.py | 4 +- tests/utils/list_resource.py | 16 + workos/directory_sync.py | 406 +++------------ workos/organizations.py | 234 ++------- workos/resources/base.py | 5 +- workos/resources/directory_sync.py | 174 ++++--- workos/resources/list.py | 88 +++- workos/resources/organizations.py | 44 +- workos/resources/workos_model.py | 25 + workos/typing/__init__.py | 1 + workos/typing/literals.py | 46 ++ workos/typing/untyped_literal.py | 37 ++ workos/utils/pagination_order.py | 4 + workos/utils/request.py | 23 +- 24 files changed, 788 insertions(+), 1439 deletions(-) create mode 100644 mypy.ini create mode 100644 tests/utils/list_resource.py create mode 100644 workos/resources/workos_model.py create mode 100644 workos/typing/__init__.py create mode 100644 workos/typing/literals.py create mode 100644 workos/typing/untyped_literal.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc0d2ef8..c5db9d08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,5 +38,9 @@ jobs: flake8 . --extend-exclude .devbox --count --select=E9,F7,F82 --show-source --statistics flake8 . --extend-exclude .devbox --count --exit-zero --max-complexity=10 --statistics + - name: Type Check + run: | + mypy + - name: Test run: python -m pytest diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000..7fa1d2e9 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,2 @@ +[mypy] +files=./workos/resources/organizations.py,./workos/resources/directory_sync.py diff --git a/setup.py b/setup.py index 8d4c7cc3..50cd76fb 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,11 @@ ), zip_safe=False, license=about["__license__"], - install_requires=["requests>=2.22.0"], + install_requires=[ + "requests>=2.22.0", + "pydantic==2.8.2", + "types-requests==2.32.0.20240712", + ], extras_require={ "dev": [ "flake8", @@ -38,6 +42,7 @@ "twine==4.0.2", "requests==2.30.0", "urllib3==2.0.2", + "mypy==1.10.1", ], ":python_version<'3.4'": ["enum34"], }, diff --git a/tests/conftest.py b/tests/conftest.py index 57509d77..4490647a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,8 @@ +from typing import Dict import pytest import requests +from tests.utils.list_resource import list_response_of import workos @@ -45,7 +47,7 @@ def inner(method, response_dict, status_code, headers=None): def mock(*args, **kwargs): return MockResponse(response_dict, status_code, headers=headers) - monkeypatch.setattr(requests, method, mock) + monkeypatch.setattr(requests, "request", mock) return inner @@ -56,7 +58,7 @@ def inner(method, content, status_code, headers=None): def mock(*args, **kwargs): return MockRawResponse(content, status_code, headers=headers) - monkeypatch.setattr(requests, method, mock) + monkeypatch.setattr(requests, "request", mock) return inner @@ -73,8 +75,46 @@ def capture_and_mock(*args, **kwargs): return MockResponse(response_dict, status_code, headers=headers) - monkeypatch.setattr(requests, method, capture_and_mock) + monkeypatch.setattr(requests, "request", capture_and_mock) return (request_args, request_kwargs) return inner + + +@pytest.fixture +def mock_pagination_request(monkeypatch): + # Mocking pagination correctly requires us to index into a list of data + # and correctly set the before and after metadata in the response. + def inner(method, data_list, status_code, headers=None): + # For convenient index lookup, store the list of object IDs. + data_ids = list(map(lambda x: x["id"], data_list)) + + def mock(*args, **kwargs): + params = kwargs.get("params") or {} + request_after = params.get("after", None) + limit = params.get("limit", 10) + + if request_after is None: + # First page + start = 0 + else: + # A subsequent page, return the first item _after_ the index we locate + start = data_ids.index(request_after) + 1 + data = data_list[start : start + limit] + if len(data) < limit or len(data) == 0: + # No more data, set after to None + after = None + else: + # Set after to the last item in this page of results + after = data[-1]["id"] + + return MockResponse( + list_response_of(data=data, before=request_after, after=after), + status_code, + headers=headers, + ) + + monkeypatch.setattr(requests, "request", mock) + + return inner diff --git a/tests/test_directory_sync.py b/tests/test_directory_sync.py index 7e01952d..3f391b27 100644 --- a/tests/test_directory_sync.py +++ b/tests/test_directory_sync.py @@ -1,9 +1,13 @@ import pytest +import requests +from tests.conftest import MockResponse, mock_pagination_request +from tests.utils.list_resource import list_data_to_dicts, list_response_of from workos.directory_sync import DirectorySync -from workos.resources.directory_sync import WorkOSDirectoryUser +from workos.resources.directory_sync import DirectoryUser from tests.utils.fixtures.mock_directory import MockDirectory from tests.utils.fixtures.mock_directory_user import MockDirectoryUser from tests.utils.fixtures.mock_directory_group import MockDirectoryGroup +from workos.resources.list import WorkOsListResource class TestDirectorySync(object): @@ -18,182 +22,17 @@ def mock_users(self): return { "data": user_list, "list_metadata": {"before": None, "after": None}, - "metadata": { - "params": { - "domain": None, - "organization_id": None, - "search": None, - "limit": 10, - "before": None, - "after": None, - "order": None, - "default_limit": True, - }, - "method": DirectorySync.list_users, - }, - } - - @pytest.fixture - def mock_default_limit_users(self): - user_list = [MockDirectoryUser(id=str(i)).to_dict() for i in range(10)] - - return { - "data": user_list, - "list_metadata": {"before": None, "after": "xxx"}, - "metadata": { - "params": { - "domain": None, - "organization_id": None, - "search": None, - "limit": 10, - "before": None, - "after": None, - "order": None, - "default_limit": True, - }, - "method": DirectorySync.list_users, - }, - } - - @pytest.fixture - def mock_default_limit_users_v2(self): - user_list = [MockDirectoryUser(id=str(i)).to_dict() for i in range(10)] - - dict_response = { - "data": user_list, - "list_metadata": {"before": None, "after": "xxx"}, - "metadata": { - "params": { - "domain": None, - "organization_id": None, - "search": None, - "limit": 10, - "before": None, - "after": None, - "order": None, - "default_limit": True, - }, - "method": DirectorySync.list_users, - }, - } - - return self.directory_sync.construct_from_response(dict_response) - - @pytest.fixture - def mock_users_pagination_response(self): - user_list = [MockDirectoryUser(id=str(i)).to_dict() for i in range(90)] - - return { - "data": user_list, - "list_metadata": {"before": None, "after": None}, - "metadata": { - "params": { - "domain": None, - "organization_id": None, - "search": None, - "limit": 10, - "before": None, - "after": None, - "order": None, - "default_limit": True, - }, - "method": DirectorySync.list_users, - }, + "object": "list", } @pytest.fixture def mock_groups(self): - group_list = [MockDirectoryGroup(id=str(i)).to_dict() for i in range(5000)] - - return { - "data": group_list, - "list_metadata": {"before": None, "after": "xxx"}, - "metadata": { - "params": { - "domain": None, - "organization_id": None, - "search": None, - "limit": 10, - "before": None, - "after": None, - "order": None, - "default_limit": True, - }, - "method": DirectorySync.list_groups, - }, - } - - @pytest.fixture - def mock_default_limit_groups(self): - group_list = [MockDirectoryGroup(id=str(i)).to_dict() for i in range(10)] - - return { - "data": group_list, - "list_metadata": {"before": None, "after": "xxx"}, - "metadata": { - "params": { - "domain": None, - "organization_id": None, - "search": None, - "limit": 10, - "before": None, - "after": None, - "order": None, - "default_limit": True, - }, - "method": DirectorySync.list_groups, - }, - } - - @pytest.fixture - def mock_default_limit_groups_v2(self): - group_list = [MockDirectoryGroup(id=str(i)).to_dict() for i in range(10)] - - dict_response = { - "data": group_list, - "list_metadata": {"before": None, "after": "xxx"}, - "metadata": { - "params": { - "domain": None, - "organization_id": None, - "search": None, - "limit": 10, - "before": None, - "after": None, - "order": None, - "default_limit": True, - }, - "method": DirectorySync.list_groups, - }, - } - - return self.directory_sync.construct_from_response(dict_response) - - @pytest.fixture - def mock_groups_pagination_reponse(self): - group_list = [MockDirectoryGroup(id=str(i)).to_dict() for i in range(4990)] - - return { - "data": group_list, - "list_metadata": {"before": None, "after": None}, - "metadata": { - "params": { - "domain": None, - "organization_id": None, - "search": None, - "limit": 10, - "before": None, - "after": None, - "order": None, - "default_limit": True, - }, - "method": DirectorySync.list_groups, - }, - } + group_list = [MockDirectoryGroup(id=str(i)).to_dict() for i in range(20)] + return list_response_of(data=group_list, after="xxx") @pytest.fixture def mock_user_primary_email(self): - return {"primary": "true", "type": "work", "value": "marcelina@foo-corp.com"} + return {"primary": True, "type": "work", "value": "marcelina@foo-corp.com"} @pytest.fixture def mock_user(self): @@ -203,6 +42,7 @@ def mock_user(self): def mock_user_no_email(self): return { "id": "directory_user_01E1JG7J09H96KYP8HM9B0GZZZ", + "object": "directory_user", "idp_id": "2836", "directory_id": "directory_01ECAZ4NV9QMV47GW873HDCX74", "organization_id": "org_01EZTR6WYX1A0DSE2CYMGXQ24Y", @@ -214,6 +54,10 @@ def mock_user_no_email(self): "groups": [ { "id": "directory_group_01E64QTDNS0EGJ0FMCVY9BWGZT", + "directory_id": "directory_01ECAZ4NV9QMV47GW873HDCX74", + "organization_id": "org_01EZTR6WYX1A0DSE2CYMGXQ24Y", + "object": "directory_group", + "idp_id": "2836", "name": "Engineering", "created_at": "2021-06-25T19:07:33.155Z", "updated_at": "2021-06-25T19:07:33.155Z", @@ -235,137 +79,26 @@ def mock_group(self): @pytest.fixture def mock_directories(self): - directory_list = [MockDirectory(id=str(i)).to_dict() for i in range(5000)] - - return { - "data": directory_list, - "list_metadata": {"before": None, "after": None}, - "metadata": { - "params": { - "domain": None, - "organization_id": None, - "search": None, - "limit": None, - "before": None, - "after": None, - "order": None, - "default_limit": True, - }, - "method": DirectorySync.list_directories, - }, - } + directory_list = [MockDirectory(id=str(i)).to_dict() for i in range(20)] + return list_response_of(data=directory_list) @pytest.fixture - def mock_directories_with_limit(self): - directory_list = [MockDirectory(id=str(i)).to_dict() for i in range(4)] - - return { - "data": directory_list, - "list_metadata": {"before": None, "after": None}, - "metadata": { - "params": { - "domain": None, - "organization_id": None, - "search": None, - "limit": 4, - "before": None, - "after": None, - "order": None, - }, - "method": DirectorySync.list_directories, - }, - } + def mock_directory_users_multiple_data_pages(self): + return [ + MockDirectoryUser(id=str(f"directory_user_{i}")).to_dict() + for i in range(40) + ] @pytest.fixture - def mock_directories_with_limit_v2(self): - directory_list = [MockDirectory(id=str(i)).to_dict() for i in range(4)] - - dict_response = { - "data": directory_list, - "list_metadata": {"before": None, "after": None}, - "metadata": { - "params": { - "domain": None, - "organization_id": None, - "search": None, - "limit": 4, - "before": None, - "after": None, - "order": None, - }, - "method": DirectorySync.list_directories, - }, - } - - return self.directory_sync.construct_from_response(dict_response) + def mock_directories_multiple_data_pages(self): + return [MockDirectory(id=str(f"dir_{i}")).to_dict() for i in range(40)] @pytest.fixture - def mock_default_limit_directories(self): - directory_list = [MockDirectory(id=str(i)).to_dict() for i in range(10)] - - return { - "data": directory_list, - "list_metadata": {"before": None, "after": "directory_id_xx"}, - "metadata": { - "params": { - "domain": None, - "organization_id": None, - "search": None, - "limit": 10, - "before": None, - "after": None, - "order": None, - "default_limit": True, - }, - "method": DirectorySync.list_directories, - }, - } - - @pytest.fixture - def mock_default_limit_directories_v2(self): - directory_list = [MockDirectory(id=str(i)).to_dict() for i in range(10)] - - dict_response = { - "data": directory_list, - "list_metadata": {"before": None, "after": "directory_id_xx"}, - "metadata": { - "params": { - "domain": None, - "organization_id": None, - "search": None, - "limit": 10, - "before": None, - "after": None, - "order": None, - "default_limit": True, - }, - "method": DirectorySync.list_directories, - }, - } - - return self.directory_sync.construct_from_response(dict_response) - - @pytest.fixture - def mock_directories_pagination_response(self): - directory_list = [MockDirectory(id=str(i)).to_dict() for i in range(4990)] - - return { - "data": directory_list, - "list_metadata": {"before": None, "after": None}, - "metadata": { - "params": { - "domain": None, - "organization_id": None, - "search": None, - "limit": None, - "before": None, - "after": None, - "order": None, - "default_limit": True, - }, - "method": DirectorySync.list_directories, - }, - } + def mock_directory_groups_multiple_data_pages(self): + return [ + MockDirectoryGroup(id=str(f"directory_group_{i}")).to_dict() + for i in range(40) + ] @pytest.fixture def mock_directory(self): @@ -376,35 +109,35 @@ def test_list_users_with_directory(self, mock_users, mock_request_method): users = self.directory_sync.list_users(directory="directory_id") - assert users == mock_users + assert list_data_to_dicts(users.data) == mock_users["data"] def test_list_users_with_group(self, mock_users, mock_request_method): mock_request_method("get", mock_users, 200) users = self.directory_sync.list_users(group="directory_grp_id") - assert users == mock_users + assert list_data_to_dicts(users.data) == mock_users["data"] def test_list_groups_with_directory(self, mock_groups, mock_request_method): mock_request_method("get", mock_groups, 200) groups = self.directory_sync.list_groups(directory="directory_id") - assert groups == mock_groups + assert list_data_to_dicts(groups.data) == mock_groups["data"] def test_list_groups_with_user(self, mock_groups, mock_request_method): mock_request_method("get", mock_groups, 200) groups = self.directory_sync.list_groups(user="directory_usr_id") - assert groups == mock_groups + assert list_data_to_dicts(groups.data) == mock_groups["data"] def test_get_user(self, mock_user, mock_request_method): mock_request_method("get", mock_user, 200) user = self.directory_sync.get_user(user="directory_usr_id") - assert user == mock_user + assert user.dict() == mock_user def test_get_group(self, mock_group, mock_request_method): mock_request_method("get", mock_group, 200) @@ -413,21 +146,21 @@ def test_get_group(self, mock_group, mock_request_method): group="directory_group_01FHGRYAQ6ERZXXXXXX1E01QFE" ) - assert group == mock_group + assert group.dict() == mock_group def test_list_directories(self, mock_directories, mock_request_method): mock_request_method("get", mock_directories, 200) directories = self.directory_sync.list_directories() - assert directories == mock_directories + assert list_data_to_dicts(directories.data) == mock_directories["data"] def test_get_directory(self, mock_directory, mock_request_method): mock_request_method("get", mock_directory, 200) directory = self.directory_sync.get_directory(directory="directory_id") - assert directory == mock_directory + assert directory.dict() == mock_directory def test_delete_directory(self, mock_directories, mock_raw_request_method): mock_raw_request_method( @@ -448,162 +181,89 @@ def test_primary_email( mock_user_instance = self.directory_sync.get_user( "directory_user_01E1JG7J09H96KYP8HM9B0G5SJ" ) - primary_email = WorkOSDirectoryUser.construct_from_response( - mock_user_instance - ).primary_email() - - assert primary_email == mock_user_primary_email + primary_email = mock_user_instance.primary_email() + assert primary_email + assert primary_email.dict() == mock_user_primary_email def test_primary_email_none(self, mock_user_no_email, mock_request_method): mock_request_method("get", mock_user_no_email, 200) mock_user_instance = self.directory_sync.get_user( "directory_user_01E1JG7J09H96KYP8HM9B0G5SJ" ) - primary_email = WorkOSDirectoryUser.construct_from_response(mock_user_instance) - me = primary_email.primary_email() + + me = mock_user_instance.primary_email() assert me == None - def test_directories_auto_pagination( + def test_list_directories_auto_pagination( self, - mock_default_limit_directories, - mock_directories_pagination_response, - mock_directories, - mock_request_method, + mock_directories_multiple_data_pages, + mock_pagination_request, ): - mock_request_method("get", mock_directories_pagination_response, 200) - - directories = mock_default_limit_directories - - all_directories = DirectorySync.construct_from_response( - directories - ).auto_paging_iter() + mock_pagination_request("get", mock_directories_multiple_data_pages, 200) - assert len(*list(all_directories)) == len(mock_directories["data"]) + directories = self.directory_sync.list_directories() + all_directories = [] - def test_list_directories_auto_pagination_v2( - self, - mock_default_limit_directories_v2, - mock_directories_pagination_response, - mock_directories, - mock_request_method, - ): - directories = mock_default_limit_directories_v2 - mock_request_method("get", mock_directories_pagination_response, 200) - all_directories = directories.auto_paging_iter() + for directory in directories.auto_paging_iter(): + all_directories.append(directory) - assert len(*list(all_directories)) == len(mock_directories["data"]) + assert len(list(all_directories)) == len(mock_directories_multiple_data_pages) + assert ( + list_data_to_dicts(all_directories) + ) == mock_directories_multiple_data_pages def test_directory_users_auto_pagination( self, - mock_users, - mock_default_limit_users, - mock_users_pagination_response, - mock_request_method, + mock_directory_users_multiple_data_pages, + mock_pagination_request, ): - mock_request_method("get", mock_users_pagination_response, 200) - users = mock_default_limit_users - - all_users = DirectorySync.construct_from_response(users).auto_paging_iter() - - assert len(*list(all_users)) == len(mock_users["data"]) + mock_pagination_request("get", mock_directory_users_multiple_data_pages, 200) - def test_directory_users_auto_pagination_v2( - self, - mock_users, - mock_default_limit_users_v2, - mock_users_pagination_response, - mock_request_method, - ): - mock_request_method("get", mock_users_pagination_response, 200) - users = mock_default_limit_users_v2 + users = self.directory_sync.list_users() + all_users = [] - all_users = users.auto_paging_iter() + for user in users.auto_paging_iter(): + all_users.append(user) - assert len(*list(all_users)) == len(mock_users["data"]) + assert len(list(all_users)) == len(mock_directory_users_multiple_data_pages) + assert ( + list_data_to_dicts(all_users) + ) == mock_directory_users_multiple_data_pages def test_directory_user_groups_auto_pagination( self, - mock_groups, - mock_default_limit_groups, - mock_groups_pagination_reponse, - mock_request_method, + mock_directory_groups_multiple_data_pages, + mock_pagination_request, ): - mock_request_method("get", mock_groups_pagination_reponse, 200) + mock_pagination_request("get", mock_directory_groups_multiple_data_pages, 200) - groups = mock_default_limit_groups - all_groups = DirectorySync.construct_from_response(groups).auto_paging_iter() + groups = self.directory_sync.list_groups() + all_groups = [] - assert len(*list(all_groups)) == len(mock_groups["data"]) + for group in groups.auto_paging_iter(): + all_groups.append(group) - def test_directory_user_groups_auto_pagination_v2( - self, - mock_groups, - mock_default_limit_groups_v2, - mock_groups_pagination_reponse, - mock_request_method, - ): - mock_request_method("get", mock_groups_pagination_reponse, 200) - - groups = mock_default_limit_groups_v2 - all_groups = groups.auto_paging_iter() - - assert len(*list(all_groups)) == len(mock_groups["data"]) + assert len(list(all_groups)) == len(mock_directory_groups_multiple_data_pages) + assert ( + list_data_to_dicts(all_groups) + ) == mock_directory_groups_multiple_data_pages def test_auto_pagination_honors_limit( self, - mock_directories_with_limit, - mock_directories_pagination_response, - mock_request_method, - ): - mock_request_method("get", mock_directories_pagination_response, 200) - - directories = mock_directories_with_limit - - all_directories = DirectorySync.construct_from_response( - directories - ).auto_paging_iter() - - assert len(*list(all_directories)) == len(mock_directories_with_limit["data"]) - - def test_auto_pagination_honors_limit_v2( - self, - mock_directories_with_limit_v2, - mock_directories_pagination_response, - mock_request_method, - ): - mock_request_method("get", mock_directories_pagination_response, 200) - - directories = mock_directories_with_limit_v2 - dict_directories = mock_directories_with_limit_v2.to_dict() - all_directories = directories.auto_paging_iter() - - assert len(*list(all_directories)) == len(dict_directories["data"]) - - def test_list_directories_returns_metadata( - self, - mock_directories, - mock_request_method, + mock_directories_multiple_data_pages, + mock_pagination_request, ): - mock_request_method("get", mock_directories, 200) - directories = self.directory_sync.list_directories( - organization="Planet Express" - ) + # TODO: This does not actually test anything about the limit. + mock_pagination_request("get", mock_directories_multiple_data_pages, 200) - assert directories["metadata"]["params"]["organization_id"] == "Planet Express" + directories = self.directory_sync.list_directories() + all_directories = [] - def test_list_directories_returns_metadata_v2( - self, - mock_directories, - mock_request_method, - ): - mock_request_method("get", mock_directories, 200) - directories = self.directory_sync.list_directories_v2( - organization="Planet Express" - ) - dict_directories = directories.to_dict() + for directory in directories.auto_paging_iter(): + all_directories.append(directory) + assert len(list(all_directories)) == len(mock_directories_multiple_data_pages) assert ( - dict_directories["metadata"]["params"]["organization_id"] - == "Planet Express" - ) + list_data_to_dicts(all_directories) + ) == mock_directories_multiple_data_pages diff --git a/tests/test_organizations.py b/tests/test_organizations.py index ed43aaad..e559cb33 100644 --- a/tests/test_organizations.py +++ b/tests/test_organizations.py @@ -1,4 +1,11 @@ +import datetime +from typing import Dict, List, Union, cast + import pytest +import requests + +from tests.conftest import MockResponse +from tests.utils.list_resource import list_data_to_dicts, list_response_of from workos.organizations import Organizations from tests.utils.fixtures.mock_organization import MockOrganization @@ -17,6 +24,8 @@ def mock_organization_updated(self): return { "name": "Example Organization", "object": "organization", + "created_at": datetime.datetime.now().isoformat(), + "updated_at": datetime.datetime.now().isoformat(), "id": "org_01EHT88Z8J8795GZNQ4ZP1J81T", "allow_profiles_outside_organization": True, "domains": [ @@ -30,151 +39,39 @@ def mock_organization_updated(self): @pytest.fixture def mock_organizations(self): - organization_list = [MockOrganization(id=str(i)).to_dict() for i in range(5000)] - - return { - "data": organization_list, - "list_metadata": {"before": None, "after": None}, - "metadata": { - "params": { - "domains": None, - "limit": None, - "before": None, - "after": None, - "order": None, - "default_limit": True, - }, - "method": Organizations.list_organizations, - }, - } - - @pytest.fixture - def mock_organizations_v2(self): - organization_list = [MockOrganization(id=str(i)).to_dict() for i in range(5000)] - - dict_response = { - "data": organization_list, - "list_metadata": {"before": None, "after": None}, - "metadata": { - "params": { - "domains": None, - "limit": None, - "before": None, - "after": None, - "order": None, - "default_limit": True, - }, - "method": Organizations.list_organizations_v2, - }, - } - return dict_response - - @pytest.fixture - def mock_organizations_with_limit(self): - organization_list = [MockOrganization(id=str(i)).to_dict() for i in range(4)] + organization_list = [MockOrganization(id=str(i)).to_dict() for i in range(10)] return { "data": organization_list, "list_metadata": {"before": None, "after": None}, - "metadata": { - "params": { - "domains": None, - "limit": 4, - "before": None, - "after": None, - "order": None, - }, - "method": Organizations.list_organizations, - }, + "object": "list", } @pytest.fixture - def mock_organizations_with_limit_v2(self): - organization_list = [MockOrganization(id=str(i)).to_dict() for i in range(4)] - - dict_response = { - "data": organization_list, - "list_metadata": {"before": None, "after": None}, - "metadata": { - "params": { - "domains": None, - "limit": 4, - "before": None, - "after": None, - "order": None, - }, - "method": Organizations.list_organizations_v2, - }, - } - return self.organizations.construct_from_response(dict_response) - - @pytest.fixture - def mock_organizations_with_default_limit(self): + def mock_organizations_single_page_response(self): organization_list = [MockOrganization(id=str(i)).to_dict() for i in range(10)] - return { "data": organization_list, - "list_metadata": {"before": None, "after": "org_id_xxx"}, - "metadata": { - "params": { - "domains": None, - "limit": None, - "before": None, - "after": None, - "order": None, - "default_limit": True, - }, - "method": Organizations.list_organizations, - }, - } - - @pytest.fixture - def mock_organizations_with_default_limit_v2(self): - organization_list = [MockOrganization(id=str(i)).to_dict() for i in range(10)] - - dict_response = { - "data": organization_list, - "list_metadata": {"before": None, "after": "org_id_xxx"}, - "metadata": { - "params": { - "domains": None, - "limit": None, - "before": None, - "after": None, - "order": None, - "default_limit": True, - }, - "method": Organizations.list_organizations_v2, - }, + "list_metadata": {"before": None, "after": None}, + "object": "list", } - return self.organizations.construct_from_response(dict_response) @pytest.fixture - def mock_organizations_pagination_response(self): - organization_list = [MockOrganization(id=str(i)).to_dict() for i in range(4990)] - - return { - "data": organization_list, - "list_metadata": {"before": None, "after": None}, - "metadata": { - "params": { - "domains": None, - "limit": None, - "before": None, - "after": None, - "order": None, - "default_limit": True, - }, - "method": Organizations.list_organizations, - }, - } + def mock_organizations_multiple_data_pages(self): + return [MockOrganization(id=str(f"org_{i+1}")).to_dict() for i in range(25)] def test_list_organizations(self, mock_organizations, mock_request_method): - mock_request_method("get", {"data": mock_organizations}, 200) + mock_request_method("get", mock_organizations, 200) organizations_response = self.organizations.list_organizations() - assert organizations_response["data"] == mock_organizations + def to_dict(x): + return x.dict() + + assert ( + list(map(to_dict, organizations_response.data)) + == mock_organizations["data"] + ) def test_get_organization(self, mock_organization, mock_request_method): mock_request_method("get", mock_organization, 200) @@ -183,7 +80,7 @@ def test_get_organization(self, mock_organization, mock_request_method): organization="organization_id" ) - assert organization == mock_organization + assert organization.dict() == mock_organization def test_get_organization_by_lookup_key( self, mock_organization, mock_request_method @@ -194,7 +91,7 @@ def test_get_organization_by_lookup_key( lookup_key="test" ) - assert organization == mock_organization + assert organization.dict() == mock_organization def test_create_organization_with_domain_data( self, mock_organization, mock_request_method @@ -205,41 +102,27 @@ def test_create_organization_with_domain_data( "domain_data": [{"domain": "example.com", "state": "verified"}], "name": "Test Organization", } - organization = self.organizations.create_organization(payload) + organization = self.organizations.create_organization(**payload) - assert organization["id"] == "org_01EHT88Z8J8795GZNQ4ZP1J81T" - assert organization["name"] == "Foo Corporation" + assert organization.id == "org_01EHT88Z8J8795GZNQ4ZP1J81T" + assert organization.name == "Foo Corporation" - def test_create_organization_with_domains( - self, mock_organization, mock_request_method - ): - mock_request_method("post", mock_organization, 201) - - payload = {"domains": ["example.com"], "name": "Test Organization"} - with pytest.warns( - DeprecationWarning, - match="The 'domains' parameter for 'create_organization' is deprecated.", - ): - organization = self.organizations.create_organization(payload) - - assert organization["id"] == "org_01EHT88Z8J8795GZNQ4ZP1J81T" - assert organization["name"] == "Foo Corporation" - - def test_sends_idempotency_key(self, capture_and_mock_request): + def test_sends_idempotency_key(self, mock_organization, capture_and_mock_request): idempotency_key = "test_123456789" + payload = { "domain_data": [{"domain": "example.com", "state": "verified"}], "name": "Foo Corporation", } - _, request_kwargs = capture_and_mock_request("post", payload, 200) + _, request_kwargs = capture_and_mock_request("post", mock_organization, 200) response = self.organizations.create_organization( - payload, idempotency_key=idempotency_key + **payload, idempotency_key=idempotency_key ) assert request_kwargs["headers"]["idempotency-key"] == idempotency_key - assert response["name"] == "Foo Corporation" + assert response.name == "Foo Corporation" def test_update_organization_with_domain_data( self, mock_organization_updated, mock_request_method @@ -250,46 +133,17 @@ def test_update_organization_with_domain_data( organization="org_01EHT88Z8J8795GZNQ4ZP1J81T", name="Example Organization", domain_data=[{"domain": "example.io", "state": "verified"}], - allow_profiles_outside_organization=True, ) - assert updated_organization["id"] == "org_01EHT88Z8J8795GZNQ4ZP1J81T" - assert updated_organization["name"] == "Example Organization" - assert updated_organization["domains"] == [ + assert updated_organization.id == "org_01EHT88Z8J8795GZNQ4ZP1J81T" + assert updated_organization.name == "Example Organization" + assert updated_organization.domains == [ { "domain": "example.io", "object": "organization_domain", "id": "org_domain_01EHT88Z8WZEFWYPM6EC9BX2R8", } ] - assert updated_organization["allow_profiles_outside_organization"] - - def test_update_organization_with_domains( - self, mock_organization_updated, mock_request_method - ): - mock_request_method("put", mock_organization_updated, 201) - - with pytest.warns( - DeprecationWarning, - match="The 'domains' parameter for 'update_organization' is deprecated.", - ): - updated_organization = self.organizations.update_organization( - organization="org_01EHT88Z8J8795GZNQ4ZP1J81T", - name="Example Organization", - domains=["example.io"], - allow_profiles_outside_organization=True, - ) - - assert updated_organization["id"] == "org_01EHT88Z8J8795GZNQ4ZP1J81T" - assert updated_organization["name"] == "Example Organization" - assert updated_organization["domains"] == [ - { - "domain": "example.io", - "object": "organization_domain", - "id": "org_domain_01EHT88Z8WZEFWYPM6EC9BX2R8", - } - ] - assert updated_organization["allow_profiles_outside_organization"] def test_delete_organization(self, setup, mock_raw_request_method): mock_raw_request_method( @@ -303,97 +157,41 @@ def test_delete_organization(self, setup, mock_raw_request_method): assert response is None - def test_list_organizations_auto_pagination( + def test_list_organizations_auto_pagination_for_single_page( self, - mock_organizations_with_default_limit, - mock_organizations_pagination_response, + mock_organizations_single_page_response, mock_organizations, mock_request_method, ): - mock_request_method("get", mock_organizations_pagination_response, 200) - - organizations = mock_organizations_with_default_limit - - all_organizations = Organizations.construct_from_response( - organizations - ).auto_paging_iter() - - assert len(*list(all_organizations)) == len(mock_organizations["data"]) - - def test_list_organizations_auto_pagination_v2( - self, - mock_organizations_with_default_limit_v2, - mock_organizations_pagination_response, - mock_organizations, - mock_request_method, - ): - mock_request_method("get", mock_organizations_pagination_response, 200) - - organizations = mock_organizations_with_default_limit_v2 - - all_organizations = organizations.auto_paging_iter() - - assert len(*list(all_organizations)) == len(mock_organizations["data"]) - - def test_list_organizations_honors_limit( - self, - mock_organizations_with_limit, - mock_organizations_pagination_response, - mock_request_method, - ): - mock_request_method("get", mock_organizations_pagination_response, 200) - - organizations = mock_organizations_with_limit - - all_organizations = Organizations.construct_from_response( - organizations - ).auto_paging_iter() + mock_request_method("get", mock_organizations_single_page_response, 200) - assert len(*list(all_organizations)) == len( - mock_organizations_with_limit["data"] - ) - - def test_list_organizations_honors_limit_v2( - self, - mock_organizations_with_limit_v2, - mock_organizations_pagination_response, - mock_request_method, - ): - mock_request_method("get", mock_organizations_pagination_response, 200) + all_organizations = [] + organizations = self.organizations.list_organizations() - organizations = mock_organizations_with_limit_v2 + for org in organizations.auto_paging_iter(): + all_organizations.append(org) - all_organizations = organizations.auto_paging_iter() - dict_response = organizations.to_dict() + assert len(list(all_organizations)) == 10 - assert len(*list(all_organizations)) == len(dict_response["data"]) + organization_data = mock_organizations_single_page_response["data"] + assert (list_data_to_dicts(all_organizations)) == organization_data - def test_list_organizations_returns_metadata( + def test_list_organizations_auto_pagination_for_multiple_pages( self, - mock_organizations, - mock_request_method, + mock_organizations_multiple_data_pages, + mock_pagination_request, ): - mock_request_method("get", mock_organizations, 200) + mock_pagination_request("get", mock_organizations_multiple_data_pages, 200) - organizations = self.organizations.list_organizations( - domains=["planet-express.com"] - ) + all_organizations = [] + organizations = self.organizations.list_organizations() - assert organizations["metadata"]["params"]["domains"] == ["planet-express.com"] + for org in organizations.auto_paging_iter(): + all_organizations.append(org) - def test_list_organizations_returns_metadata_v2( - self, - mock_organizations_v2, - mock_request_method, - ): - mock_request_method("get", mock_organizations_v2, 200) - - organizations = self.organizations.list_organizations_v2( - domains=["planet-express.com"] + assert len(list(all_organizations)) == len( + mock_organizations_multiple_data_pages ) - - dict_organizations = organizations.to_dict() - - assert dict_organizations["metadata"]["params"]["domains"] == [ - "planet-express.com" - ] + assert ( + list_data_to_dicts(all_organizations) + ) == mock_organizations_multiple_data_pages diff --git a/tests/test_user_management.py b/tests/test_user_management.py index cd81834f..fe33bef4 100644 --- a/tests/test_user_management.py +++ b/tests/test_user_management.py @@ -254,11 +254,12 @@ def mock_invitations(self): return dict_response def test_get_user(self, mock_user, capture_and_mock_request): - url, request_kwargs = capture_and_mock_request("get", mock_user, 200) + request_args, request_kwargs = capture_and_mock_request("get", mock_user, 200) user = self.user_management.get_user("user_01H7ZGXFP5C6BBQY6Z7277ZCT0") - - assert url[0].endswith("user_management/users/user_01H7ZGXFP5C6BBQY6Z7277ZCT0") + assert request_args[1].endswith( + "user_management/users/user_01H7ZGXFP5C6BBQY6Z7277ZCT0" + ) assert user["id"] == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" assert user["profile_picture_url"] == "https://example.com/profile-picture.jpg" @@ -317,7 +318,7 @@ def test_create_user(self, mock_user, mock_request_method): assert user["id"] == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" def test_update_user(self, mock_user, capture_and_mock_request): - url, request = capture_and_mock_request("put", mock_user, 200) + request_args, request = capture_and_mock_request("put", mock_user, 200) user = self.user_management.update_user( "user_01H7ZGXFP5C6BBQY6Z7277ZCT0", @@ -329,7 +330,7 @@ def test_update_user(self, mock_user, capture_and_mock_request): }, ) - assert url[0].endswith("users/user_01H7ZGXFP5C6BBQY6Z7277ZCT0") + assert request_args[1].endswith("users/user_01H7ZGXFP5C6BBQY6Z7277ZCT0") assert user["id"] == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" assert request["json"]["first_name"] == "Marcelina" assert request["json"]["last_name"] == "Hoeger" @@ -337,11 +338,13 @@ def test_update_user(self, mock_user, capture_and_mock_request): assert request["json"]["password"] == "password" def test_delete_user(self, capture_and_mock_request): - url, request_kwargs = capture_and_mock_request("delete", None, 200) + request_args, request_kwargs = capture_and_mock_request("delete", None, 200) user = self.user_management.delete_user("user_01H7ZGXFP5C6BBQY6Z7277ZCT0") - assert url[0].endswith("user_management/users/user_01H7ZGXFP5C6BBQY6Z7277ZCT0") + assert request_args[1].endswith( + "user_management/users/user_01H7ZGXFP5C6BBQY6Z7277ZCT0" + ) assert user is None def test_create_organization_membership( @@ -349,40 +352,48 @@ def test_create_organization_membership( ): user_id = "user_12345" organization_id = "org_67890" - url, _ = capture_and_mock_request("post", mock_organization_membership, 201) + request_args, _ = capture_and_mock_request( + "post", mock_organization_membership, 201 + ) organization_membership = self.user_management.create_organization_membership( user_id=user_id, organization_id=organization_id ) - assert url[0].endswith("user_management/organization_memberships") + assert request_args[1].endswith("user_management/organization_memberships") assert organization_membership["user_id"] == user_id assert organization_membership["organization_id"] == organization_id def test_update_organization_membership( self, capture_and_mock_request, mock_organization_membership ): - url, _ = capture_and_mock_request("put", mock_organization_membership, 201) + request_args, _ = capture_and_mock_request( + "put", mock_organization_membership, 201 + ) organization_membership = self.user_management.update_organization_membership( organization_membership_id="om_ABCDE", role_slug="member", ) - assert url[0].endswith("user_management/organization_memberships/om_ABCDE") + assert request_args[1].endswith( + "user_management/organization_memberships/om_ABCDE" + ) assert organization_membership["id"] == "om_ABCDE" assert organization_membership["role"] == {"slug": "member"} def test_get_organization_membership( self, mock_organization_membership, capture_and_mock_request ): - url, request_kwargs = capture_and_mock_request( + request_args, request_kwargs = capture_and_mock_request( "get", mock_organization_membership, 200 ) om = self.user_management.get_organization_membership("om_ABCDE") - assert url[0].endswith("user_management/organization_memberships/om_ABCDE") + assert request_args[1].endswith( + "user_management/organization_memberships/om_ABCDE" + ) assert om["id"] == "om_ABCDE" def test_list_organization_memberships_returns_metadata( @@ -432,23 +443,25 @@ def test_list_organization_memberships_with_a_single_status_returns_metadata( assert dict_oms["metadata"]["params"]["organization_id"] == "org_12345" def test_delete_organization_membership(self, capture_and_mock_request): - url, request_kwargs = capture_and_mock_request("delete", None, 200) + request_args, request_kwargs = capture_and_mock_request("delete", None, 200) user = self.user_management.delete_organization_membership("om_ABCDE") - assert url[0].endswith("user_management/organization_memberships/om_ABCDE") + assert request_args[1].endswith( + "user_management/organization_memberships/om_ABCDE" + ) assert user is None def test_deactivate_organization_membership( self, mock_organization_membership, capture_and_mock_request ): - url, request_kwargs = capture_and_mock_request( + request_args, request_kwargs = capture_and_mock_request( "put", mock_organization_membership, 200 ) om = self.user_management.deactivate_organization_membership("om_ABCDE") - assert url[0].endswith( + assert request_args[1].endswith( "user_management/organization_memberships/om_ABCDE/deactivate" ) assert om["id"] == "om_ABCDE" @@ -456,13 +469,13 @@ def test_deactivate_organization_membership( def test_reactivate_organization_membership( self, mock_organization_membership, capture_and_mock_request ): - url, request_kwargs = capture_and_mock_request( + request_args, request_kwargs = capture_and_mock_request( "put", mock_organization_membership, 200 ) om = self.user_management.reactivate_organization_membership("om_ABCDE") - assert url[0].endswith( + assert request_args[1].endswith( "user_management/organization_memberships/om_ABCDE/reactivate" ) assert om["id"] == "om_ABCDE" @@ -476,7 +489,9 @@ def test_authorization_url_throws_value_error_without_redirect_uri(self): connection_id=connection_id, login_hint=login_hint, state=state, - ) + ) # type: ignore + # TODO: ignore above added temporarily, this runtime error isn't needed with modern python type validation + # leaving this as a reminder to remove the runtime check. def test_authorization_url_throws_value_error_with_missing_connection_organization_and_provider( self, @@ -517,8 +532,7 @@ def test_authorization_url_has_expected_query_params_with_connection_id(self): ) parsed_url = urlparse(authorization_url) - - assert dict(parse_qsl(parsed_url.query)) == { + assert dict(parse_qsl(str(parsed_url.query))) == { "connection_id": connection_id, "client_id": workos.client_id, "redirect_uri": redirect_uri, @@ -534,8 +548,7 @@ def test_authorization_url_has_expected_query_params_with_organization_id(self): ) parsed_url = urlparse(authorization_url) - - assert dict(parse_qsl(parsed_url.query)) == { + assert dict(parse_qsl(str(parsed_url.query))) == { "organization_id": organization_id, "client_id": workos.client_id, "redirect_uri": redirect_uri, @@ -550,8 +563,7 @@ def test_authorization_url_has_expected_query_params_with_provider(self): ) parsed_url = urlparse(authorization_url) - - assert dict(parse_qsl(parsed_url.query)) == { + assert dict(parse_qsl(str(parsed_url.query))) == { "provider": provider.value, "client_id": workos.client_id, "redirect_uri": redirect_uri, @@ -570,8 +582,7 @@ def test_authorization_url_has_expected_query_params_with_domain_hint(self): ) parsed_url = urlparse(authorization_url) - - assert dict(parse_qsl(parsed_url.query)) == { + assert dict(parse_qsl(str(parsed_url.query))) == { "domain_hint": domain_hint, "client_id": workos.client_id, "redirect_uri": redirect_uri, @@ -591,8 +602,7 @@ def test_authorization_url_has_expected_query_params_with_login_hint(self): ) parsed_url = urlparse(authorization_url) - - assert dict(parse_qsl(parsed_url.query)) == { + assert dict(parse_qsl(str(parsed_url.query))) == { "login_hint": login_hint, "client_id": workos.client_id, "redirect_uri": redirect_uri, @@ -612,8 +622,7 @@ def test_authorization_url_has_expected_query_params_with_state(self): ) parsed_url = urlparse(authorization_url) - - assert dict(parse_qsl(parsed_url.query)) == { + assert dict(parse_qsl(str(parsed_url.query))) == { "state": state, "client_id": workos.client_id, "redirect_uri": redirect_uri, @@ -633,8 +642,7 @@ def test_authorization_url_has_expected_query_params_with_code_challenge(self): ) parsed_url = urlparse(authorization_url) - - assert dict(parse_qsl(parsed_url.query)) == { + assert dict(parse_qsl(str(parsed_url.query))) == { "code_challenge": code_challenge, "code_challenge_method": "S256", "client_id": workos.client_id, @@ -651,7 +659,9 @@ def test_authenticate_with_password( user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" ip_address = "192.0.0.1" - url, request = capture_and_mock_request("post", mock_auth_response, 200) + request_args, request = capture_and_mock_request( + "post", mock_auth_response, 200 + ) response = self.user_management.authenticate_with_password( email=email, @@ -660,7 +670,7 @@ def test_authenticate_with_password( ip_address=ip_address, ) - assert url[0].endswith("user_management/authenticate") + assert request_args[1].endswith("user_management/authenticate") assert response["user"]["id"] == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" assert response["organization_id"] == "org_12345" assert response["access_token"] == "access_token_12345" @@ -679,7 +689,9 @@ def test_authenticate_with_code(self, capture_and_mock_request, mock_auth_respon user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" ip_address = "192.0.0.1" - url, request = capture_and_mock_request("post", mock_auth_response, 200) + request_args, request = capture_and_mock_request( + "post", mock_auth_response, 200 + ) response = self.user_management.authenticate_with_code( code=code, @@ -688,7 +700,7 @@ def test_authenticate_with_code(self, capture_and_mock_request, mock_auth_respon ip_address=ip_address, ) - assert url[0].endswith("user_management/authenticate") + assert request_args[1].endswith("user_management/authenticate") assert response["user"]["id"] == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" assert response["organization_id"] == "org_12345" assert response["access_token"] == "access_token_12345" @@ -706,7 +718,7 @@ def test_authenticate_impersonator_with_code( ): code = "test_code" - url, request = capture_and_mock_request( + request_args, request = capture_and_mock_request( "post", mock_auth_response_with_impersonator, 200 ) @@ -714,8 +726,7 @@ def test_authenticate_impersonator_with_code( code=code, ) - print(response) - assert url[0].endswith("user_management/authenticate") + assert request_args[1].endswith("user_management/authenticate") assert response["user"]["id"] == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" assert response["impersonator"]["email"] == "admin@foocorp.com" assert response["impersonator"]["reason"] == "Debugging an account issue." @@ -728,7 +739,9 @@ def test_authenticate_with_magic_auth( user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" ip_address = "192.0.0.1" - url, request = capture_and_mock_request("post", mock_auth_response, 200) + request_args, request = capture_and_mock_request( + "post", mock_auth_response, 200 + ) response = self.user_management.authenticate_with_magic_auth( code=code, @@ -737,7 +750,7 @@ def test_authenticate_with_magic_auth( ip_address=ip_address, ) - assert url[0].endswith("user_management/authenticate") + assert request_args[1].endswith("user_management/authenticate") assert response["user"]["id"] == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" assert response["organization_id"] == "org_12345" assert response["access_token"] == "access_token_12345" @@ -761,7 +774,9 @@ def test_authenticate_with_email_verification( user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" ip_address = "192.0.0.1" - url, request = capture_and_mock_request("post", mock_auth_response, 200) + request_args, request = capture_and_mock_request( + "post", mock_auth_response, 200 + ) response = self.user_management.authenticate_with_email_verification( code=code, @@ -770,7 +785,7 @@ def test_authenticate_with_email_verification( ip_address=ip_address, ) - assert url[0].endswith("user_management/authenticate") + assert request_args[1].endswith("user_management/authenticate") assert response["user"]["id"] == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" assert response["organization_id"] == "org_12345" assert response["access_token"] == "access_token_12345" @@ -796,7 +811,9 @@ def test_authenticate_with_totp(self, capture_and_mock_request, mock_auth_respon user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" ip_address = "192.0.0.1" - url, request = capture_and_mock_request("post", mock_auth_response, 200) + request_args, request = capture_and_mock_request( + "post", mock_auth_response, 200 + ) response = self.user_management.authenticate_with_totp( code=code, @@ -806,7 +823,7 @@ def test_authenticate_with_totp(self, capture_and_mock_request, mock_auth_respon ip_address=ip_address, ) - assert url[0].endswith("user_management/authenticate") + assert request_args[1].endswith("user_management/authenticate") assert response["user"]["id"] == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" assert response["organization_id"] == "org_12345" assert response["access_token"] == "access_token_12345" @@ -834,7 +851,9 @@ def test_authenticate_with_organization_selection( user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" ip_address = "192.0.0.1" - url, request = capture_and_mock_request("post", mock_auth_response, 200) + request_args, request = capture_and_mock_request( + "post", mock_auth_response, 200 + ) response = self.user_management.authenticate_with_organization_selection( organization_id=organization_id, @@ -843,7 +862,7 @@ def test_authenticate_with_organization_selection( ip_address=ip_address, ) - assert url[0].endswith("user_management/authenticate") + assert request_args[1].endswith("user_management/authenticate") assert response["user"]["id"] == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" assert response["organization_id"] == "org_12345" assert response["access_token"] == "access_token_12345" @@ -869,7 +888,7 @@ def test_authenticate_with_refresh_token( user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" ip_address = "192.0.0.1" - url, request = capture_and_mock_request( + request_args, request = capture_and_mock_request( "post", mock_auth_refresh_token_response, 200 ) @@ -879,7 +898,7 @@ def test_authenticate_with_refresh_token( ip_address=ip_address, ) - assert url[0].endswith("user_management/authenticate") + assert request_args[1].endswith("user_management/authenticate") assert response["access_token"] == "access_token_12345" assert response["refresh_token"] == "refresh_token_12345" assert request["json"]["user_agent"] == user_agent @@ -905,27 +924,31 @@ def test_get_logout_url(self): assert expected == result def test_get_password_reset(self, mock_password_reset, capture_and_mock_request): - url, request_kwargs = capture_and_mock_request("get", mock_password_reset, 200) + request_args, request_kwargs = capture_and_mock_request( + "get", mock_password_reset, 200 + ) password_reset = self.user_management.get_password_reset("password_reset_ABCDE") - assert url[0].endswith("user_management/password_reset/password_reset_ABCDE") + assert request_args[1].endswith( + "user_management/password_reset/password_reset_ABCDE" + ) assert password_reset["id"] == "password_reset_ABCDE" def test_create_password_reset(self, capture_and_mock_request, mock_password_reset): email = "marcelina@foo-corp.com" - url, _ = capture_and_mock_request("post", mock_password_reset, 201) + request_args, _ = capture_and_mock_request("post", mock_password_reset, 201) password_reset = self.user_management.create_password_reset(email=email) - assert url[0].endswith("user_management/password_reset") + assert request_args[1].endswith("user_management/password_reset") assert password_reset["email"] == email def test_send_password_reset_email(self, capture_and_mock_request): email = "marcelina@foo-corp.com" password_reset_url = "https://foo-corp.com/reset-password" - url, request = capture_and_mock_request("post", None, 200) + request_args, request = capture_and_mock_request("post", None, 200) with pytest.warns( DeprecationWarning, @@ -936,7 +959,7 @@ def test_send_password_reset_email(self, capture_and_mock_request): password_reset_url=password_reset_url, ) - assert url[0].endswith("user_management/password_reset/send") + assert request_args[1].endswith("user_management/password_reset/send") assert request["json"]["email"] == email assert request["json"]["password_reset_url"] == password_reset_url assert response is None @@ -945,14 +968,16 @@ def test_reset_password(self, capture_and_mock_request, mock_user): token = "token123" new_password = "pass123" - url, request = capture_and_mock_request("post", {"user": mock_user}, 200) + request_args, request = capture_and_mock_request( + "post", {"user": mock_user}, 200 + ) response = self.user_management.reset_password( token=token, new_password=new_password, ) - assert url[0].endswith("user_management/password_reset/confirm") + assert request_args[1].endswith("user_management/password_reset/confirm") assert response["id"] == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" assert request["json"]["token"] == token assert request["json"]["new_password"] == new_password @@ -960,7 +985,7 @@ def test_reset_password(self, capture_and_mock_request, mock_user): def test_get_email_verification( self, mock_email_verification, capture_and_mock_request ): - url, request_kwargs = capture_and_mock_request( + request_args, request_kwargs = capture_and_mock_request( "get", mock_email_verification, 200 ) @@ -968,7 +993,7 @@ def test_get_email_verification( "email_verification_ABCDE" ) - assert url[0].endswith( + assert request_args[1].endswith( "user_management/email_verification/email_verification_ABCDE" ) assert email_verification["id"] == "email_verification_ABCDE" @@ -976,11 +1001,11 @@ def test_get_email_verification( def test_send_verification_email(self, capture_and_mock_request, mock_user): user_id = "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" - url, _ = capture_and_mock_request("post", {"user": mock_user}, 200) + request_args, _ = capture_and_mock_request("post", {"user": mock_user}, 200) response = self.user_management.send_verification_email(user_id=user_id) - assert url[0].endswith( + assert request_args[1].endswith( "user_management/users/user_01H7ZGXFP5C6BBQY6Z7277ZCT0/email_verification/send" ) assert response["id"] == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" @@ -989,37 +1014,41 @@ def test_verify_email(self, capture_and_mock_request, mock_user): user_id = "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" code = "code_123" - url, request = capture_and_mock_request("post", {"user": mock_user}, 200) + request_args, request = capture_and_mock_request( + "post", {"user": mock_user}, 200 + ) response = self.user_management.verify_email(user_id=user_id, code=code) - assert url[0].endswith( + assert request_args[1].endswith( "user_management/users/user_01H7ZGXFP5C6BBQY6Z7277ZCT0/email_verification/confirm" ) assert request["json"]["code"] == code assert response["id"] == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" def test_get_magic_auth(self, mock_magic_auth, capture_and_mock_request): - url, request_kwargs = capture_and_mock_request("get", mock_magic_auth, 200) + request_args, request_kwargs = capture_and_mock_request( + "get", mock_magic_auth, 200 + ) magic_auth = self.user_management.get_magic_auth("magic_auth_ABCDE") - assert url[0].endswith("user_management/magic_auth/magic_auth_ABCDE") + assert request_args[1].endswith("user_management/magic_auth/magic_auth_ABCDE") assert magic_auth["id"] == "magic_auth_ABCDE" def test_create_magic_auth(self, capture_and_mock_request, mock_magic_auth): email = "marcelina@foo-corp.com" - url, _ = capture_and_mock_request("post", mock_magic_auth, 201) + request_args, _ = capture_and_mock_request("post", mock_magic_auth, 201) magic_auth = self.user_management.create_magic_auth(email=email) - assert url[0].endswith("user_management/magic_auth") + assert request_args[1].endswith("user_management/magic_auth") assert magic_auth["email"] == email def test_send_magic_auth_code(self, capture_and_mock_request): email = "marcelina@foo-corp.com" - url, request = capture_and_mock_request("post", None, 200) + request_args, request = capture_and_mock_request("post", None, 200) with pytest.warns( DeprecationWarning, @@ -1027,7 +1056,7 @@ def test_send_magic_auth_code(self, capture_and_mock_request): ): response = self.user_management.send_magic_auth_code(email=email) - assert url[0].endswith("user_management/magic_auth/send") + assert request_args[1].endswith("user_management/magic_auth/send") assert request["json"]["email"] == email assert response is None @@ -1067,21 +1096,25 @@ def test_auth_factors_returns_metadata( assert dict_auth_factors["metadata"]["params"]["user_id"] == "user_12345" def test_get_invitation(self, mock_invitation, capture_and_mock_request): - url, request_kwargs = capture_and_mock_request("get", mock_invitation, 200) + request_args, request_kwargs = capture_and_mock_request( + "get", mock_invitation, 200 + ) invitation = self.user_management.get_invitation("invitation_ABCDE") - assert url[0].endswith("user_management/invitations/invitation_ABCDE") + assert request_args[1].endswith("user_management/invitations/invitation_ABCDE") assert invitation["id"] == "invitation_ABCDE" def test_find_invitation_by_token(self, mock_invitation, capture_and_mock_request): - url, request_kwargs = capture_and_mock_request("get", mock_invitation, 200) + request_args, request_kwargs = capture_and_mock_request( + "get", mock_invitation, 200 + ) invitation = self.user_management.find_invitation_by_token( "Z1uX3RbwcIl5fIGJJJCXXisdI" ) - assert url[0].endswith( + assert request_args[1].endswith( "user_management/invitations/by_token/Z1uX3RbwcIl5fIGJJJCXXisdI" ) assert invitation["token"] == "Z1uX3RbwcIl5fIGJJJCXXisdI" @@ -1103,19 +1136,21 @@ def test_list_invitations_returns_metadata( def test_send_invitation(self, capture_and_mock_request, mock_invitation): email = "marcelina@foo-corp.com" organization_id = "org_12345" - url, _ = capture_and_mock_request("post", mock_invitation, 201) + request_args, _ = capture_and_mock_request("post", mock_invitation, 201) invitation = self.user_management.send_invitation( email=email, organization_id=organization_id ) - assert url[0].endswith("user_management/invitations") + assert request_args[1].endswith("user_management/invitations") assert invitation["email"] == email assert invitation["organization_id"] == organization_id def test_revoke_invitation(self, capture_and_mock_request, mock_invitation): - url, _ = capture_and_mock_request("post", mock_invitation, 200) + request_args, _ = capture_and_mock_request("post", mock_invitation, 200) - user = self.user_management.revoke_invitation("invitation_ABCDE") + self.user_management.revoke_invitation("invitation_ABCDE") - assert url[0].endswith("user_management/invitations/invitation_ABCDE/revoke") + assert request_args[1].endswith( + "user_management/invitations/invitation_ABCDE/revoke" + ) diff --git a/tests/utils/fixtures/mock_directory.py b/tests/utils/fixtures/mock_directory.py index 68e1292e..9d1cd391 100644 --- a/tests/utils/fixtures/mock_directory.py +++ b/tests/utils/fixtures/mock_directory.py @@ -4,14 +4,16 @@ class MockDirectory(WorkOSBaseResource): def __init__(self, id): + now = datetime.datetime.now().isoformat() self.object = "directory" self.id = id + self.organization_id = "organization_id" self.domain = "crashlanding.com" self.name = "Ri Jeong Hyeok" - self.state = None - self.type = "gsuite_directory" - self.created_at = datetime.datetime.now() - self.updated_at = datetime.datetime.now() + self.state = "linked" + self.type = "gsuite directory" + self.created_at = now + self.updated_at = now OBJECT_FIELDS = [ "object", diff --git a/tests/utils/fixtures/mock_directory_group.py b/tests/utils/fixtures/mock_directory_group.py index 87a259fe..20229c5e 100644 --- a/tests/utils/fixtures/mock_directory_group.py +++ b/tests/utils/fixtures/mock_directory_group.py @@ -4,19 +4,22 @@ class MockDirectoryGroup(WorkOSBaseResource): def __init__(self, id): + now = datetime.datetime.now().isoformat() self.id = id self.idp_id = "idp_id_" + id self.directory_id = "directory_id" + self.organization_id = "organization_id" self.name = "group_" + id - self.created_at = datetime.datetime.now() - self.updated_at = datetime.datetime.now() - self.raw_attributes = None + self.created_at = now + self.updated_at = now + self.raw_attributes = {} self.object = "directory_group" OBJECT_FIELDS = [ "id", "idp_id", "directory_id", + "organization_id", "name", "created_at", "updated_at", diff --git a/tests/utils/fixtures/mock_directory_user.py b/tests/utils/fixtures/mock_directory_user.py index dc644a4a..5e2f57ea 100644 --- a/tests/utils/fixtures/mock_directory_user.py +++ b/tests/utils/fixtures/mock_directory_user.py @@ -4,6 +4,7 @@ class MockDirectoryUser(WorkOSBaseResource): def __init__(self, id): + now = datetime.datetime.now().isoformat() self.id = id self.idp_id = "idp_id_" + id self.directory_id = "directory_id" @@ -12,14 +13,14 @@ def __init__(self, id): self.last_name = "fried chicken" self.job_title = "developer" self.emails = [ - {"primary": "true", "type": "work", "value": "marcelina@foo-corp.com"} + {"primary": True, "type": "work", "value": "marcelina@foo-corp.com"} ] self.username = None - self.groups = None - self.state = None - self.created_at = datetime.datetime.now() - self.updated_at = datetime.datetime.now() - self.custom_attributes = None + self.groups = [] + self.state = "active" + self.created_at = now + self.updated_at = now + self.custom_attributes = {} self.raw_attributes = { "schemas": ["urn:scim:schemas:core:1.0"], "name": {"familyName": "Seri", "givenName": "Marcelina"}, diff --git a/tests/utils/fixtures/mock_organization.py b/tests/utils/fixtures/mock_organization.py index 72a43bb9..b4ebe3ed 100644 --- a/tests/utils/fixtures/mock_organization.py +++ b/tests/utils/fixtures/mock_organization.py @@ -8,8 +8,8 @@ def __init__(self, id): self.object = "organization" self.name = "Foo Corporation" self.allow_profiles_outside_organization = False - self.created_at = datetime.datetime.now() - self.updated_at = datetime.datetime.now() + self.created_at = datetime.datetime.now().isoformat() + self.updated_at = datetime.datetime.now().isoformat() self.domains = [ { "domain": "example.io", diff --git a/tests/utils/list_resource.py b/tests/utils/list_resource.py new file mode 100644 index 00000000..390dea31 --- /dev/null +++ b/tests/utils/list_resource.py @@ -0,0 +1,16 @@ +from typing import Dict, List, Optional, TypeVar, TypedDict, Union +from workos.resources.list import ListPage, WorkOsListResource + + +def list_data_to_dicts(list_data: List): + return list(map(lambda x: x.dict(), list_data)) + + +def list_response_of( + data=[Dict], before: Optional[str] = None, after: Optional[str] = None +): + return { + "object": "list", + "data": data, + "list_metadata": {"before": before, "after": after}, + } diff --git a/workos/directory_sync.py b/workos/directory_sync.py index 2a1dd43a..6f06127d 100644 --- a/workos/directory_sync.py +++ b/workos/directory_sync.py @@ -1,6 +1,6 @@ -from warnings import warn +from typing import Optional import workos -from workos.utils.pagination_order import Order +from workos.utils.pagination_order import PaginationOrder from workos.utils.request import ( RequestHelper, REQUEST_METHOD_DELETE, @@ -9,17 +9,17 @@ from workos.utils.validation import DIRECTORY_SYNC_MODULE, validate_settings from workos.resources.directory_sync import ( - WorkOSDirectoryGroup, - WorkOSDirectory, - WorkOSDirectoryUser, + DirectoryGroup, + Directory, + DirectoryUser, ) -from workos.resources.list import WorkOSListResource +from workos.resources.list import ListArgs, ListPage, WorkOsListResource RESPONSE_LIMIT = 10 -class DirectorySync(WorkOSListResource): +class DirectorySync: """Offers methods through the WorkOS Directory Sync service.""" @validate_settings(DIRECTORY_SYNC_MODULE) @@ -34,13 +34,13 @@ def request_helper(self): def list_users( self, - directory=None, - group=None, - limit=None, - before=None, - after=None, - order=None, - ): + directory: Optional[str] = None, + group: Optional[str] = None, + limit: int = RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> WorkOsListResource[DirectoryUser]: """Gets a list of provisioned Users for a Directory. Note, either 'directory' or 'group' must be provided. @@ -56,20 +56,12 @@ def list_users( Returns: dict: Directory Users response from WorkOS. """ - warn( - "The 'list_users' method is deprecated. Please use 'list_users_v2' instead.", - DeprecationWarning, - ) - - if limit is None: - limit = RESPONSE_LIMIT - default_limit = True params = { "limit": limit, "before": before, "after": after, - "order": order or "desc", + "order": order, } if group is not None: @@ -77,14 +69,6 @@ def list_users( if directory is not None: params["directory"] = directory - if order is not None: - if isinstance(order, Order): - params["order"] = str(order.value) - elif order == "asc" or order == "desc": - params["order"] = order - else: - raise ValueError("Parameter order must be of enum type Order") - response = self.request_helper.request( "directory_users", method=REQUEST_METHOD_GET, @@ -92,97 +76,22 @@ def list_users( token=workos.api_key, ) - if "default_limit" in locals(): - if "metadata" in response and "params" in response["metadata"]: - response["metadata"]["params"]["default_limit"] = default_limit - else: - response["metadata"] = {"params": {"default_limit": default_limit}} - - response["metadata"] = { - "params": params, - "method": DirectorySync.list_users, - } - - return response - - def list_users_v2( - self, - directory=None, - group=None, - limit=None, - before=None, - after=None, - order=None, - ): - """Gets a list of provisioned Users for a Directory. - - Note, either 'directory' or 'group' must be provided. - - Args: - directory (str): Directory unique identifier. - group (str): Directory Group unique identifier. - limit (int): Maximum number of records to return. - before (str): Pagination cursor to receive records before a provided Directory ID. - after (str): Pagination cursor to receive records after a provided Directory ID. - order (Order): Sort records in either ascending or descending order by created_at timestamp. - - Returns: - dict: Directory Users response from WorkOS. - """ - - if limit is None: - limit = RESPONSE_LIMIT - default_limit = True - - params = { - "limit": limit, - "before": before, - "after": after, - "order": order or "desc", - } - - if group is not None: - params["group"] = group - if directory is not None: - params["directory"] = directory - - if order is not None: - if isinstance(order, Order): - params["order"] = str(order.value) - elif order == "asc" or order == "desc": - params["order"] = order - else: - raise ValueError("Parameter order must be of enum type Order") - - response = self.request_helper.request( - "directory_users", - method=REQUEST_METHOD_GET, - params=params, - token=workos.api_key, + return WorkOsListResource( + list_method=self.list_users, + # TODO: Should we even bother with this validation? + list_args=ListArgs.model_validate(params), + **ListPage[DirectoryUser](**response).model_dump() ) - if "default_limit" in locals(): - if "metadata" in response and "params" in response["metadata"]: - response["metadata"]["params"]["default_limit"] = default_limit - else: - response["metadata"] = {"params": {"default_limit": default_limit}} - - response["metadata"] = { - "params": params, - "method": DirectorySync.list_users_v2, - } - - return self.construct_from_response(response) - def list_groups( self, - directory=None, - user=None, - limit=None, - before=None, - after=None, - order=None, - ): + directory: Optional[str] = None, + user: Optional[str] = None, + limit: int = RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> WorkOsListResource[DirectoryGroup]: """Gets a list of provisioned Groups for a Directory . Note, either 'directory' or 'user' must be provided. @@ -198,33 +107,17 @@ def list_groups( Returns: dict: Directory Groups response from WorkOS. """ - warn( - "The 'list_groups' method is deprecated. Please use 'list_groups_v2' instead.", - DeprecationWarning, - ) - if limit is None: - limit = RESPONSE_LIMIT - default_limit = True - params = { "limit": limit, "before": before, "after": after, - "order": order or "desc", + "order": order, } if user is not None: params["user"] = user if directory is not None: params["directory"] = directory - if order is not None: - if isinstance(order, Order): - params["order"] = str(order.value) - elif order == "asc" or order == "desc": - params["order"] = order - else: - raise ValueError("Parameter order must be of enum type Order") - response = self.request_helper.request( "directory_groups", method=REQUEST_METHOD_GET, @@ -232,87 +125,14 @@ def list_groups( token=workos.api_key, ) - if "default_limit" in locals(): - if "metadata" in response and "params" in response["metadata"]: - response["metadata"]["params"]["default_limit"] = default_limit - else: - response["metadata"] = {"params": {"default_limit": default_limit}} - - response["metadata"] = { - "params": params, - "method": DirectorySync.list_groups, - } - - return response - - def list_groups_v2( - self, - directory=None, - user=None, - limit=None, - before=None, - after=None, - order=None, - ): - """Gets a list of provisioned Groups for a Directory . - - Note, either 'directory' or 'user' must be provided. - - Args: - directory (str): Directory unique identifier. - user (str): Directory User unique identifier. - limit (int): Maximum number of records to return. - before (str): Pagination cursor to receive records before a provided Directory ID. - after (str): Pagination cursor to receive records after a provided Directory ID. - order (Order): Sort records in either ascending or descending order by created_at timestamp. - - Returns: - dict: Directory Groups response from WorkOS. - """ - if limit is None: - limit = RESPONSE_LIMIT - default_limit = True - - params = { - "limit": limit, - "before": before, - "after": after, - "order": order or "desc", - } - if user is not None: - params["user"] = user - if directory is not None: - params["directory"] = directory - - if order is not None: - if isinstance(order, Order): - params["order"] = str(order.value) - elif order == "asc" or order == "desc": - params["order"] = order - else: - raise ValueError("Parameter order must be of enum type Order") - - response = self.request_helper.request( - "directory_groups", - method=REQUEST_METHOD_GET, - params=params, - token=workos.api_key, + return WorkOsListResource( + list_method=self.list_groups, + # TODO: Should we even bother with this validation? + list_args=ListArgs.model_validate(params), + **ListPage[DirectoryGroup](**response).model_dump() ) - if "default_limit" in locals(): - if "metadata" in response and "params" in response["metadata"]: - response["metadata"]["params"]["default_limit"] = default_limit - else: - response["metadata"] = {"params": {"default_limit": default_limit}} - - response["metadata"] = { - "params": params, - "method": DirectorySync.list_groups_v2, - } - - return self.construct_from_response(response) - - def get_user(self, user): + def get_user(self, user: str): """Gets details for a single provisioned Directory User. Args: @@ -327,9 +147,9 @@ def get_user(self, user): token=workos.api_key, ) - return WorkOSDirectoryUser.construct_from_response(response).to_dict() + return DirectoryUser.model_validate(response) - def get_group(self, group): + def get_group(self, group: str): """Gets details for a single provisioned Directory Group. Args: @@ -343,10 +163,9 @@ def get_group(self, group): method=REQUEST_METHOD_GET, token=workos.api_key, ) + return DirectoryGroup.model_validate(response) - return WorkOSDirectoryGroup.construct_from_response(response).to_dict() - - def get_directory(self, directory): + def get_directory(self, directory: str): """Gets details for a single Directory Args: @@ -363,18 +182,18 @@ def get_directory(self, directory): token=workos.api_key, ) - return WorkOSDirectory.construct_from_response(response).to_dict() + return Directory.model_validate(response) def list_directories( self, - domain=None, - search=None, - limit=None, - before=None, - after=None, - organization=None, - order=None, - ): + domain: Optional[str] = None, + search: Optional[str] = None, + limit: int = RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + organization: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> WorkOsListResource[Directory]: """Gets details for existing Directories. Args: @@ -389,151 +208,40 @@ def list_directories( Returns: dict: Directories response from WorkOS. """ - warn( - "The 'list_directories' method is deprecated. Please use 'list_directories_v2' instead.", - DeprecationWarning, - ) - - if limit is None: - limit = RESPONSE_LIMIT - default_limit = True params = { "domain": domain, - "organization_id": organization, + "organization": organization, "search": search, "limit": limit, "before": before, "after": after, - "order": order or "desc", + "order": order, } - if order is not None: - if isinstance(order, Order): - params["order"] = str(order.value) - - elif order == "asc" or order == "desc": - params["order"] = order - else: - raise ValueError("Parameter order must be of enum type Order") - response = self.request_helper.request( "directories", method=REQUEST_METHOD_GET, params=params, token=workos.api_key, ) - - response["metadata"] = { - "params": params, - "method": DirectorySync.list_directories, - } - - if "default_limit" in locals(): - if "metadata" in response and "params" in response["metadata"]: - response["metadata"]["params"]["default_limit"] = default_limit - else: - response["metadata"] = {"params": {"default_limit": default_limit}} - - return response - - def list_directories_v2( - self, - domain=None, - search=None, - limit=None, - before=None, - after=None, - organization=None, - order=None, - ): - """Gets details for existing Directories. - - Args: - domain (str): Domain of a Directory. (Optional) - organization: ID of an Organization (Optional) - search (str): Searchable text for a Directory. (Optional) - limit (int): Maximum number of records to return. (Optional) - before (str): Pagination cursor to receive records before a provided Directory ID. (Optional) - after (str): Pagination cursor to receive records after a provided Directory ID. (Optional) - order (Order): Sort records in either ascending or descending order by created_at timestamp. - - Returns: - dict: Directories response from WorkOS. - """ - - if limit is None: - limit = RESPONSE_LIMIT - default_limit = True - - params = { - "domain": domain, - "organization_id": organization, - "search": search, - "limit": limit, - "before": before, - "after": after, - "order": order or "desc", - } - - if order is not None: - if isinstance(order, Order): - params["order"] = str(order.value) - - elif order == "asc" or order == "desc": - params["order"] = order - else: - raise ValueError("Parameter order must be of enum type Order") - - response = self.request_helper.request( - "directories", - method=REQUEST_METHOD_GET, - params=params, - token=workos.api_key, - ) - - response["metadata"] = { - "params": params, - "method": DirectorySync.list_directories_v2, - } - - if "default_limit" in locals(): - if "metadata" in response and "params" in response["metadata"]: - response["metadata"]["params"]["default_limit"] = default_limit - else: - response["metadata"] = {"params": {"default_limit": default_limit}} - - return self.construct_from_response(response) - - def get_directory(self, directory): - """Gets details for a single Directory - - Args: - directory (str): Directory unique identifier. - - Returns: - dict: Directory response from WorkOS - - """ - - response = self.request_helper.request( - "directories/{directory}".format(directory=directory), - method=REQUEST_METHOD_GET, - token=workos.api_key, + return WorkOsListResource( + list_method=self.list_directories, + # TODO: Should we even bother with this validation? + list_args=ListArgs.model_validate(params), + **ListPage[Directory](**response).model_dump() ) - return WorkOSDirectory.construct_from_response(response).to_dict() - - def delete_directory(self, directory): + def delete_directory(self, directory: str): """Delete one existing Directory. Args: directory (str): The ID of the directory to be deleted. (Required) Returns: - dict: Directories response from WorkOS. + None """ - return self.request_helper.request( + self.request_helper.request( "directories/{directory}".format(directory=directory), method=REQUEST_METHOD_DELETE, token=workos.api_key, diff --git a/workos/organizations.py b/workos/organizations.py index 818bd436..5360899f 100644 --- a/workos/organizations.py +++ b/workos/organizations.py @@ -1,6 +1,6 @@ -from warnings import warn +from typing import List, Optional import workos -from workos.utils.pagination_order import Order +from workos.utils.pagination_order import PaginationOrder from workos.utils.request import ( RequestHelper, REQUEST_METHOD_DELETE, @@ -9,14 +9,17 @@ REQUEST_METHOD_PUT, ) from workos.utils.validation import ORGANIZATIONS_MODULE, validate_settings -from workos.resources.organizations import WorkOSOrganization -from workos.resources.list import WorkOSListResource +from workos.resources.organizations import ( + Organization, + DomainDataInput, +) +from workos.resources.list import ListPage, WorkOsListResource, ListArgs ORGANIZATIONS_PATH = "organizations" RESPONSE_LIMIT = 10 -class Organizations(WorkOSListResource): +class Organizations: @validate_settings(ORGANIZATIONS_MODULE) def __init__(self): pass @@ -29,12 +32,12 @@ def request_helper(self): def list_organizations( self, - domains=None, - limit=None, - before=None, - after=None, - order=None, - ): + domains: Optional[List[str]] = None, + limit: int = RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> WorkOsListResource[Organization]: """Retrieve a list of organizations that have connections configured within your WorkOS dashboard. Kwargs: @@ -47,31 +50,15 @@ def list_organizations( Returns: dict: Organizations response from WorkOS. """ - warn( - "The 'list_organizations' method is deprecated. Please use 'list_organizations_v2' instead.", - DeprecationWarning, - ) - if limit is None: - limit = RESPONSE_LIMIT - default_limit = True params = { "domains": domains, "limit": limit, "before": before, "after": after, - "order": order or "desc", + "order": order, } - if order is not None: - if isinstance(order, Order): - params["order"] = str(order.value) - - elif order == "asc" or order == "desc": - params["order"] = order - else: - raise ValueError("Parameter order must be of enum type Order") - response = self.request_helper.request( ORGANIZATIONS_PATH, method=REQUEST_METHOD_GET, @@ -79,82 +66,14 @@ def list_organizations( token=workos.api_key, ) - response["metadata"] = { - "params": params, - "method": Organizations.list_organizations, - } - - if "default_limit" in locals(): - if "metadata" in response and "params" in response["metadata"]: - response["metadata"]["params"]["default_limit"] = default_limit - else: - response["metadata"] = {"params": {"default_limit": default_limit}} - - return response - - def list_organizations_v2( - self, - domains=None, - limit=None, - before=None, - after=None, - order=None, - ): - """Retrieve a list of organizations that have connections configured within your WorkOS dashboard. - - Kwargs: - domains (list): Filter organizations to only return those that are associated with the provided domains. (Optional) - limit (int): Maximum number of records to return. (Optional) - before (str): Pagination cursor to receive records before a provided Organization ID. (Optional) - after (str): Pagination cursor to receive records after a provided Organization ID. (Optional) - order (Order): Sort records in either ascending or descending order by created_at timestamp. - - Returns: - dict: Organizations response from WorkOS. - """ - - if limit is None: - limit = RESPONSE_LIMIT - default_limit = True - - params = { - "domains": domains, - "limit": limit, - "before": before, - "after": after, - "order": order or "desc", - } - - if order is not None: - if isinstance(order, Order): - params["order"] = str(order.value) - - elif order == "asc" or order == "desc": - params["order"] = order - else: - raise ValueError("Parameter order must be of enum type Order") - - response = self.request_helper.request( - ORGANIZATIONS_PATH, - method=REQUEST_METHOD_GET, - params=params, - token=workos.api_key, + return WorkOsListResource[Organization]( + list_method=self.list_organizations, + # TODO: Should we even bother with this validation? + list_args=ListArgs.model_validate(params), + **ListPage[Organization](**response).model_dump() ) - response["metadata"] = { - "params": params, - "method": Organizations.list_organizations_v2, - } - - if "default_limit" in locals(): - if "metadata" in response and "params" in response["metadata"]: - response["metadata"]["params"]["default_limit"] = default_limit - else: - response["metadata"] = {"params": {"default_limit": default_limit}} - - return self.construct_from_response(response) - - def get_organization(self, organization): + def get_organization(self, organization: str) -> Organization: """Gets details for a single Organization Args: organization (str): Organization's unique identifier @@ -167,9 +86,9 @@ def get_organization(self, organization): token=workos.api_key, ) - return WorkOSOrganization.construct_from_response(response).to_dict() + return Organization.model_validate(response) - def get_organization_by_lookup_key(self, lookup_key): + def get_organization_by_lookup_key(self, lookup_key: str) -> Organization: """Gets details for a single Organization by lookup key Args: lookup_key (str): Organization's lookup key @@ -182,104 +101,45 @@ def get_organization_by_lookup_key(self, lookup_key): token=workos.api_key, ) - return WorkOSOrganization.construct_from_response(response).to_dict() - - def create_organization(self, organization, idempotency_key=None): - """Create an organization - - Args: - organization (dict) - An organization object - organization[name] (str) - A unique, descriptive name for the organization - organization[allow_profiles_outside_organization] (boolean) - [Deprecated] Whether Connections - within the Organization allow profiles that are outside of the Organization's - configured User Email Domains. (Optional) - organization[domains] (list[dict]) - [Deprecated] Use domain_data instead. List of domains that - belong to the organization. (Optional) - organization[domain_data] (list[dict]) - List of domains that belong to the organization. - organization[domain_data][][domain] - The domain of the organization. - organization[domain_data][][state] - The state of the domain: either 'verified' or 'pending'. - idempotency_key (str) - Idempotency key for creating an organization. (Optional) + return Organization.model_validate(response) - Returns: - dict: Created Organization response from WorkOS. - """ + def create_organization( + self, + name: str, + domain_data: Optional[List[DomainDataInput]] = None, + idempotency_key: Optional[str] = None, + ) -> Organization: + """Create an organization""" headers = {} if idempotency_key: headers["idempotency-key"] = idempotency_key - if "domains" in organization: - warn( - "The 'domains' parameter for 'create_organization' is deprecated. Please use 'domain_data' instead.", - DeprecationWarning, - ) - - if "allow_profiles_outside_organization" in organization: - warn( - "The `allow_profiles_outside_organization` parameter for `create_orgnaization` is deprecated. " - "If you need to allow sign-ins from any email domain, contact support@workos.com.", - DeprecationWarning, - ) + params = { + "name": name, + "domain_data": domain_data, + "idempotency_key": idempotency_key, + } response = self.request_helper.request( ORGANIZATIONS_PATH, method=REQUEST_METHOD_POST, - params=organization, + params=params, headers=headers, token=workos.api_key, ) - return WorkOSOrganization.construct_from_response(response).to_dict() + return Organization.model_validate(response) def update_organization( self, - organization, - name, - allow_profiles_outside_organization=None, - domains=None, - domain_data=None, - lookup_key=None, + organization: str, + name: str, + domain_data: Optional[List[DomainDataInput]] = None, ): - """Update an organization - - Args: - organization(str) - Organization's unique identifier. - name (str) - A unique, descriptive name for the organization. - allow_profiles_outside_organization (boolean) - [Deprecated] Whether Connections - within the Organization allow profiles that are outside of the Organization's - configured User Email Domains. (Optional) - domains (list) - [Deprecated] Use domain_data instead. List of domains that belong to the organization. (Optional) - domain_data (list[dict]) - List of domains that belong to the organization. (Optional) - domain_data[][domain] - The domain of the organization. - domain_data[][state] - The state of the domain: either 'verified' or 'pending'. - - Returns: - dict: Updated Organization response from WorkOS. - """ - - params = {"name": name} - - if domains is not None: - warn( - "The 'domains' parameter for 'update_organization' is deprecated. Please use 'domain_data' instead.", - DeprecationWarning, - ) - params["domains"] = domains - - if allow_profiles_outside_organization is not None: - warn( - "The `allow_profiles_outside_organization` parameter for `create_orgnaization` is deprecated. " - "If you need to allow sign-ins from any email domain, contact support@workos.com.", - DeprecationWarning, - ) - params[ - "allow_profiles_outside_organization" - ] = allow_profiles_outside_organization - - if domain_data is not None: - params["domain_data"] = domain_data - - if lookup_key is not None: - params["lookup_key"] = lookup_key + params = { + "name": name, + "domain_data": domain_data, + } response = self.request_helper.request( "organizations/{organization}".format(organization=organization), @@ -288,9 +148,9 @@ def update_organization( token=workos.api_key, ) - return WorkOSOrganization.construct_from_response(response).to_dict() + return Organization.model_validate(response) - def delete_organization(self, organization): + def delete_organization(self, organization: str): """Deletes a single Organization Args: diff --git a/workos/resources/base.py b/workos/resources/base.py index 0ff39979..b328c348 100644 --- a/workos/resources/base.py +++ b/workos/resources/base.py @@ -1,3 +1,6 @@ +from typing import List + + class WorkOSBaseResource(object): """Representation of a WorkOS Resource as returned through the API. @@ -5,7 +8,7 @@ class WorkOSBaseResource(object): OBJECT_FIELDS (list): List of fields a Resource is comprised of. """ - OBJECT_FIELDS = [] + OBJECT_FIELDS: List[str] = [] @classmethod def construct_from_response(cls, response): diff --git a/workos/resources/directory_sync.py b/workos/resources/directory_sync.py index 1c15b163..a336ddde 100644 --- a/workos/resources/directory_sync.py +++ b/workos/resources/directory_sync.py @@ -1,102 +1,110 @@ -from workos.resources.base import WorkOSBaseResource - - -class WorkOSDirectory(WorkOSBaseResource): +from typing import List, Optional, Literal +from workos.resources.workos_model import WorkOSModel +from workos.typing.literals import LiteralOrUntyped + +DirectoryState = Literal[ + "linked", + "unlinked", + "validating", + "deleting", + "invalid_credentials", +] + +DirectoryType = Literal[ + "azure scim v2.0", + "bamboohr", + "breathe hr", + "cezanne hr", + "cyperark scim v2.0", + "fourth hr", + "generic scim v2.0", + "gsuite directory", + "hibob", + "jump cloud scim v2.0", + "okta scim v2.0", + "onelogin scim v2.0", + "people hr", + "personio", + "pingfederate scim v2.0", + "rippling v2.0", + "sftp", + "sftp workday", + "workday", +] + + +class Directory(WorkOSModel): + # Should this be WorkOSDirectory? """Representation of a Directory Response as returned by WorkOS through the Directory Sync feature. Attributes: - OBJECT_FIELDS (list): List of fields a WorkOSConnection is comprised of. + OBJECT_FIELDS (list): List of fields a Directory is comprised of. """ - - OBJECT_FIELDS = [ - "object", - "id", - "domain", - "name", - "organization_id", - "state", - "type", - "created_at", - "updated_at", - ] - - @classmethod - def construct_from_response(cls, response): - connection_response = super(WorkOSDirectory, cls).construct_from_response( - response - ) - - return connection_response - - def to_dict(self): - connection_response_dict = super(WorkOSDirectory, self).to_dict() - - return connection_response_dict - - -class WorkOSDirectoryGroup(WorkOSBaseResource): + id: str + object: Literal["directory"] + domain: Optional[str] = None + name: str + organization_id: str + state: LiteralOrUntyped[DirectoryState] + type: LiteralOrUntyped[DirectoryType] + created_at: str + updated_at: str + + +class DirectoryGroup(WorkOSModel): + # Should this be WorkOSDirectoryGroup? """Representation of a Directory Group as returned by WorkOS through the Directory Sync feature. Attributes: - OBJECT_FIELDS (list): List of fields a WorkOSDirectoryGroup is comprised of. + OBJECT_FIELDS (list): List of fields a DirectoryGroup is comprised of. """ + id: str + object: Literal["directory_group"] + idp_id: str + name: str + directory_id: str + organization_id: str + raw_attributes: dict + created_at: str + updated_at: str - OBJECT_FIELDS = [ - "id", - "idp_id", - "directory_id", - "name", - "created_at", - "updated_at", - "raw_attributes", - "object", - ] - @classmethod - def construct_from_response(cls, response): - return super(WorkOSDirectoryGroup, cls).construct_from_response(response) +class DirectoryUserEmail(WorkOSModel): + type: Optional[str] = None + value: Optional[str] = None + primary: Optional[bool] = None - def to_dict(self): - directory_group = super(WorkOSDirectoryGroup, self).to_dict() - return directory_group +class Role(WorkOSModel): + slug: str -class WorkOSDirectoryUser(WorkOSBaseResource): +DirectoryUserState = Literal["active", "inactive"] + + +class DirectoryUser(WorkOSModel): + # Should this be WorkOSDirectoryUser? """Representation of a Directory User as returned by WorkOS through the Directory Sync feature. Attributes: - OBJECT_FIELDS (list): List of fields a WorkOSDirectoryUser is comprised of. + OBJECT_FIELDS (list): List of fields a DirectoryUser is comprised of. """ - - OBJECT_FIELDS = [ - "id", - "idp_id", - "directory_id", - "organization_id", - "first_name", - "last_name", - "job_title", - "emails", - "username", - "groups", - "state", - "created_at", - "updated_at", - "custom_attributes", - "raw_attributes", - "object", - "role", # [OPTIONAL] - ] - - @classmethod - def construct_from_response(cls, response): - return super(WorkOSDirectoryUser, cls).construct_from_response(response) - - def to_dict(self): - directory_group = super(WorkOSDirectoryUser, self).to_dict() - - return directory_group + id: str + object: Literal["directory_user"] + idp_id: str + directory_id: str + organization_id: str + first_name: Optional[str] = None + last_name: Optional[str] = None + job_title: Optional[str] = None + emails: List[DirectoryUserEmail] + username: Optional[str] = None + groups: List[DirectoryGroup] + state: DirectoryUserState + custom_attributes: dict + raw_attributes: dict + created_at: str + updated_at: str + role: Optional[Role] = None def primary_email(self): - self_dict = self.to_dict() - return next((email for email in self_dict["emails"] if email["primary"]), None) + return next((email for email in self.emails if email.primary), None) diff --git a/workos/resources/list.py b/workos/resources/list.py index 1a411728..6a479380 100644 --- a/workos/resources/list.py +++ b/workos/resources/list.py @@ -1,5 +1,22 @@ +from abc import abstractmethod +from typing import ( + List, + Any, + Literal, + TypeVar, + Generic, + Callable, + Iterator, + Optional, +) + from workos.resources.base import WorkOSBaseResource -from warnings import warn +from workos.resources.directory_sync import Directory, DirectoryGroup, DirectoryUser +from workos.resources.organizations import Organization + +from pydantic import BaseModel, Extra, Field + +# TODO: THIS OLD RESOURCE GOES AWAY class WorkOSListResource(WorkOSBaseResource): @@ -90,3 +107,72 @@ def auto_paging_iter(self): next_page_marker = response["list_metadata"][string_direction] yield data data = [] + + +ListableResource = TypeVar( + # add all possible generics of List Resource + "ListableResource", + Organization, + Directory, + DirectoryGroup, + DirectoryUser, +) + + +class ListMetadata(BaseModel): + after: Optional[str] = None + before: Optional[str] = None + + +class ListPage(BaseModel, Generic[ListableResource]): + object: Literal["list"] + data: List[ListableResource] + list_metadata: ListMetadata + + +class ListArgs(BaseModel, extra="allow"): + limit: Optional[int] = 10 + before: Optional[str] = None + after: Optional[str] = None + order: Literal["asc", "desc"] = "desc" + + class Config: + extra = "allow" + + +class WorkOsListResource(BaseModel, Generic[ListableResource]): + object: Literal["list"] + data: List[ListableResource] + list_metadata: ListMetadata + + # These fields end up exposed in the types. Does we care? + list_method: Callable = Field(exclude=True) + list_args: ListArgs = Field(exclude=True) + + def auto_paging_iter(self) -> Iterator[ListableResource]: + next_page: WorkOsListResource[ListableResource] + + after = self.list_metadata.after + order = self.list_args.order + + fixed_pagination_params = {"order": order, "limit": self.list_args.limit} + filter_params = self.list_args.model_dump( + exclude={"after", "before", "order", "limit"} + ) + + index: int = 0 + + while True: + if index >= len(self.data): + if after is not None: + next_page = self.list_method( + after=after, **fixed_pagination_params, **filter_params + ) + self.data = next_page.data + after = next_page.list_metadata.after + index = 0 + continue + else: + return + yield self.data[index] + index += 1 diff --git a/workos/resources/organizations.py b/workos/resources/organizations.py index dea5a976..4752ba46 100644 --- a/workos/resources/organizations.py +++ b/workos/resources/organizations.py @@ -1,29 +1,27 @@ -from workos.resources.base import WorkOSBaseResource +from typing import List, Literal, Optional, TypedDict +from workos.resources.workos_model import WorkOSModel -class WorkOSOrganization(WorkOSBaseResource): - """Representation of WorkOS Organization as returned by WorkOS through the Organizations feature. +class OrganizationDomain(WorkOSModel): + id: str + organization_id: str + object: Literal["organization_domain"] + verification_strategy: Literal["manual", "dns"] + state: Literal["failed", "pending", "legacy_verified", "verified"] + domain: str - Attributes: - OBJECT_FIELDS (list): List of fields a WorkOSOrganization is comprised of. - """ - OBJECT_FIELDS = [ - "id", - "object", - "name", - "allow_profiles_outside_organization", - "created_at", - "updated_at", - "domains", - "lookup_key", - ] +class Organization(WorkOSModel): + id: str + object: Literal["organization"] + name: str + allow_profiles_outside_organization: bool + created_at: str + updated_at: str + domains: list + lookup_key: Optional[str] = None - @classmethod - def construct_from_response(cls, response): - return super(WorkOSOrganization, cls).construct_from_response(response) - def to_dict(self): - organization = super(WorkOSOrganization, self).to_dict() - - return organization +class DomainDataInput(TypedDict): + domain: str + state: Literal["verified", "pending"] diff --git a/workos/resources/workos_model.py b/workos/resources/workos_model.py new file mode 100644 index 00000000..10c182e1 --- /dev/null +++ b/workos/resources/workos_model.py @@ -0,0 +1,25 @@ +from typing import Any, Dict +from typing_extensions import override +from pydantic import BaseModel + + +class WorkOSModel(BaseModel): + @override + def dict( + self, + *, + include=None, + exclude=None, + by_alias=False, + exclude_unset=False, + exclude_defaults=False, + exclude_none=False + ): + return self.model_dump( + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + ) diff --git a/workos/typing/__init__.py b/workos/typing/__init__.py new file mode 100644 index 00000000..6d76241d --- /dev/null +++ b/workos/typing/__init__.py @@ -0,0 +1 @@ +from workos.typing.untyped_literal import is_untyped_literal diff --git a/workos/typing/literals.py b/workos/typing/literals.py new file mode 100644 index 00000000..4cc70581 --- /dev/null +++ b/workos/typing/literals.py @@ -0,0 +1,46 @@ +from typing import Any, TypeVar, Union +from typing_extensions import Annotated, LiteralString +from pydantic import ( + Field, + ValidationError, + ValidationInfo, + ValidatorFunctionWrapHandler, + WrapValidator, +) +from workos.typing.untyped_literal import UntypedLiteral + +# Identical to the enums approach, except typed for a literal of string. + + +def convert_unknown_literal_to_untyped_literal( + value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo +) -> Union[LiteralString, UntypedLiteral]: + try: + return handler(value) + except ValidationError as validation_error: + if validation_error.errors()[0]["type"] == "literal_error": + return handler(UntypedLiteral(value)) + else: + return handler(value) + + +def allow_unknown_literal_value( + value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo +) -> Union[LiteralString, UntypedLiteral]: + try: + return handler(value) + except ValidationError as validation_error: + if validation_error.errors()[0]["type"] == "literal_error" and isinstance( + value, str + ): + return UntypedLiteral(value) + else: + return handler(value) + + +LiteralType = TypeVar("LiteralType", bound=LiteralString) +LiteralOrUntyped = Annotated[ + Annotated[Union[LiteralType, UntypedLiteral], Field(union_mode="left_to_right")], + WrapValidator(convert_unknown_literal_to_untyped_literal), +] +PermissiveLiteral = Annotated[LiteralType, WrapValidator(allow_unknown_literal_value)] diff --git a/workos/typing/untyped_literal.py b/workos/typing/untyped_literal.py new file mode 100644 index 00000000..2092b39e --- /dev/null +++ b/workos/typing/untyped_literal.py @@ -0,0 +1,37 @@ +from typing import Any +from pydantic_core import CoreSchema, core_schema +from pydantic import GetCoreSchemaHandler, TypeAdapter, ValidationError + + +class UntypedLiteral(str): + def __new__(cls, value: str): + return super().__new__(cls, f"Untyped[{value}]") + + @classmethod + def validate_untyped_literal(cls, value: Any) -> Any: + if isinstance(value, UntypedLiteral): + return value + else: + # TODO: Should this raise an error that translates to pydantic's is_instance_of error? + raise ValueError("Value is not an instance of UntypedLiteral") + + @classmethod + def __get_pydantic_core_schema__( + cls, source_type: Any, handler: GetCoreSchemaHandler + ) -> CoreSchema: + return core_schema.no_info_plain_validator_function( + function=cls.validate_untyped_literal, + ) + + +# TypeGuard doesn't actually work for exhaustiveness checking, but we can return a boolean expression instead +# https://github.com/python/mypy/issues/15305 +# TODO: see if there is a way to define this as TypeGuard, TypeIs, or bool depending on python version +# def is_untyped_literal(value: Union[str, UntypedLiteral]) -> TypeGuard[UntypedLiteral]: +# return isinstance(value, UntypedLiteral) + + +def is_untyped_literal(value: Any) -> bool: + # A helper to detect untyped values from the API (more explainer here) + # Does not help with exhaustiveness checking + return isinstance(value, UntypedLiteral) diff --git a/workos/utils/pagination_order.py b/workos/utils/pagination_order.py index 3d68f295..18a4a788 100644 --- a/workos/utils/pagination_order.py +++ b/workos/utils/pagination_order.py @@ -1,6 +1,10 @@ from enum import Enum +from typing import Literal class Order(Enum): Asc = "asc" Desc = "desc" + + +PaginationOrder = Literal["asc", "desc"] diff --git a/workos/utils/request.py b/workos/utils/request.py index c5d14bb2..e54aeafd 100644 --- a/workos/utils/request.py +++ b/workos/utils/request.py @@ -1,4 +1,6 @@ import platform +from typing import Any +import urllib.parse import requests import urllib @@ -57,7 +59,8 @@ def request( params=None, headers=None, token=None, - ): + # TODO: This isn't quite true. There are paths where this may return None. + ) -> Any: """Executes a request against the WorkOS API. Args: @@ -80,14 +83,17 @@ def request( headers.update(BASE_HEADERS) url = self.generate_api_url(path) - request_fn = getattr(requests, method) if method == REQUEST_METHOD_GET: - response = request_fn( - url, headers=headers, params=params, timeout=self.request_timeout + response = requests.request( + method, + url, + headers=headers, + params=params, + timeout=self.request_timeout, ) else: - response = request_fn( - url, headers=headers, json=params, timeout=self.request_timeout + response = requests.request( + method, url, headers=headers, json=params, timeout=self.request_timeout ) response_json = None @@ -110,8 +116,9 @@ def request( raise AuthorizationException(response) elif status_code == 404: raise NotFoundException(response) - error = response_json.get("error") - error_description = response_json.get("error_description") + if response_json is not None: + error = response_json.get("error") + error_description = response_json.get("error_description") raise BadRequestException( response, error=error, error_description=error_description ) From 2a7ac326b25ea41476dc981995aaa0c25196708b Mon Sep 17 00:00:00 2001 From: Matt Dzwonczyk <9063128+mattgd@users.noreply.github.com> Date: Tue, 23 Jul 2024 12:15:55 -0500 Subject: [PATCH 02/18] Add asyncio support for events API methods (#285) --- setup.py | 3 + tests/conftest.py | 25 +++- tests/test_async_events.py | 83 +++++++++++ tests/test_async_http_client.py | 107 ++++++++++++++ tests/test_directory_sync.py | 5 +- tests/test_events.py | 51 +++++-- tests/test_sync_http_client.py | 105 ++++++++++++++ tests/utils/fixtures/mock_event.py | 2 +- workos/__init__.py | 13 +- workos/_base_client.py | 59 ++++++++ workos/async_client.py | 68 +++++++++ workos/audit_logs.py | 31 +++- workos/client.py | 16 ++- workos/directory_sync.py | 53 ++++++- workos/events.py | 108 ++++++++++++-- workos/mfa.py | 32 ++++- workos/organizations.py | 42 +++++- workos/passwordless.py | 14 +- workos/portal.py | 23 ++- workos/sso.py | 57 +++++++- workos/user_management.py | 219 ++++++++++++++++++++++++++++- workos/utils/_base_http_client.py | 207 +++++++++++++++++++++++++++ workos/utils/http_client.py | 174 +++++++++++++++++++++++ workos/webhooks.py | 18 ++- 24 files changed, 1455 insertions(+), 60 deletions(-) create mode 100644 tests/test_async_events.py create mode 100644 tests/test_async_http_client.py create mode 100644 tests/test_sync_http_client.py create mode 100644 workos/_base_client.py create mode 100644 workos/async_client.py create mode 100644 workos/utils/_base_http_client.py create mode 100644 workos/utils/http_client.py diff --git a/setup.py b/setup.py index 50cd76fb..5face519 100644 --- a/setup.py +++ b/setup.py @@ -28,6 +28,7 @@ zip_safe=False, license=about["__license__"], install_requires=[ + "httpx>=0.27.0", "requests>=2.22.0", "pydantic==2.8.2", "types-requests==2.32.0.20240712", @@ -36,6 +37,7 @@ "dev": [ "flake8", "pytest==8.1.1", + "pytest-asyncio==0.23.8", "pytest-cov==2.8.1", "six==1.13.0", "black==22.3.0", @@ -43,6 +45,7 @@ "requests==2.30.0", "urllib3==2.0.2", "mypy==1.10.1", + "httpx>=0.27.0", ], ":python_version<'3.4'": ["enum34"], }, diff --git a/tests/conftest.py b/tests/conftest.py index 4490647a..a02a6e6c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,12 @@ -from typing import Dict +from unittest.mock import AsyncMock, MagicMock + +import httpx import pytest import requests from tests.utils.list_resource import list_response_of import workos +from workos.utils.http_client import SyncHTTPClient class MockResponse(object): @@ -118,3 +121,23 @@ def mock(*args, **kwargs): monkeypatch.setattr(requests, "request", mock) return inner + + +@pytest.fixture +def mock_sync_http_client_with_response(): + def inner(http_client: SyncHTTPClient, response_dict: dict, status_code: int): + http_client._client.request = MagicMock( + return_value=httpx.Response(status_code, json=response_dict), + ) + + return inner + + +@pytest.fixture +def mock_async_http_client_with_response(): + def inner(http_client: SyncHTTPClient, response_dict: dict, status_code: int): + http_client._client.request = AsyncMock( + return_value=httpx.Response(status_code, json=response_dict), + ) + + return inner diff --git a/tests/test_async_events.py b/tests/test_async_events.py new file mode 100644 index 00000000..19f99d98 --- /dev/null +++ b/tests/test_async_events.py @@ -0,0 +1,83 @@ +import pytest + +from tests.utils.fixtures.mock_event import MockEvent +from workos.events import AsyncEvents +from workos.utils.http_client import AsyncHTTPClient + + +class TestAsyncEvents(object): + @pytest.fixture(autouse=True) + def setup( + self, + set_api_key, + set_client_id, + ): + self.http_client = AsyncHTTPClient( + base_url="https://api.workos.test", version="test" + ) + self.events = AsyncEvents(http_client=self.http_client) + + @pytest.fixture + def mock_events(self): + events = [MockEvent(id=str(i)).to_dict() for i in range(10)] + + return { + "data": events, + "metadata": { + "params": { + "events": ["dsync.user.created"], + "limit": 10, + "organization_id": None, + "after": None, + "range_start": None, + "range_end": None, + }, + "method": AsyncEvents.list_events, + }, + } + + @pytest.mark.asyncio + async def test_list_events(self, mock_events, mock_async_http_client_with_response): + mock_async_http_client_with_response( + http_client=self.http_client, + status_code=200, + response_dict={"data": mock_events["data"]}, + ) + + events = await self.events.list_events(events=["dsync.user.created"]) + + assert events == mock_events + + @pytest.mark.asyncio + async def test_list_events_returns_metadata( + self, mock_events, mock_async_http_client_with_response + ): + mock_async_http_client_with_response( + http_client=self.http_client, + status_code=200, + response_dict={"data": mock_events["data"]}, + ) + + events = await self.events.list_events( + events=["dsync.user.created"], + ) + + assert events["metadata"]["params"]["events"] == ["dsync.user.created"] + + @pytest.mark.asyncio + async def test_list_events_with_organization_id_returns_metadata( + self, mock_events, mock_async_http_client_with_response + ): + mock_async_http_client_with_response( + http_client=self.http_client, + status_code=200, + response_dict={"data": mock_events["data"]}, + ) + + events = await self.events.list_events( + events=["dsync.user.created"], + organization_id="org_1234", + ) + + assert events["metadata"]["params"]["organization_id"] == "org_1234" + assert events["metadata"]["params"]["events"] == ["dsync.user.created"] diff --git a/tests/test_async_http_client.py b/tests/test_async_http_client.py new file mode 100644 index 00000000..187e6ee1 --- /dev/null +++ b/tests/test_async_http_client.py @@ -0,0 +1,107 @@ +from platform import python_version + +import httpx +import pytest +from unittest.mock import AsyncMock + +from workos.utils.http_client import AsyncHTTPClient + + +class TestAsyncHTTPClient(object): + @pytest.fixture(autouse=True) + def setup(self): + response = httpx.Response(200, json={"message": "Success!"}) + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"message": "Success!"}) + + self.http_client = AsyncHTTPClient( + base_url="https://api.workos.test", + version="test", + transport=httpx.MockTransport(handler), + ) + + self.http_client._client.request = AsyncMock( + return_value=response, + ) + + @pytest.mark.parametrize( + "method,status_code,expected_response", + [ + ("GET", 200, {"message": "Success!"}), + ("DELETE", 204, None), + ("DELETE", 202, None), + ], + ) + @pytest.mark.asyncio + async def test_request_without_body( + self, method: str, status_code: int, expected_response: dict + ): + self.http_client._client.request = AsyncMock( + return_value=httpx.Response( + status_code=status_code, json=expected_response + ), + ) + + response = await self.http_client.request( + "events", + method=method, + params={"test_param": "test_value"}, + token="test", + ) + + self.http_client._client.request.assert_called_with( + method=method, + url="https://api.workos.test/events", + headers=httpx.Headers( + { + "accept": "application/json", + "content-type": "application/json", + "user-agent": f"WorkOS Python/{python_version()} Python SDK/test", + "authorization": "Bearer test", + } + ), + params={"test_param": "test_value"}, + timeout=25, + ) + + assert response == expected_response + + @pytest.mark.parametrize( + "method,status_code,expected_response", + [ + ("POST", 201, {"message": "Success!"}), + ("PUT", 200, {"message": "Success!"}), + ("PATCH", 200, {"message": "Success!"}), + ], + ) + @pytest.mark.asyncio + async def test_request_with_body( + self, method: str, status_code: int, expected_response: dict + ): + self.http_client._client.request = AsyncMock( + return_value=httpx.Response( + status_code=status_code, json=expected_response + ), + ) + + response = await self.http_client.request( + "events", method=method, params={"test_param": "test_value"}, token="test" + ) + + self.http_client._client.request.assert_called_with( + method=method, + url="https://api.workos.test/events", + headers=httpx.Headers( + { + "accept": "application/json", + "content-type": "application/json", + "user-agent": f"WorkOS Python/{python_version()} Python SDK/test", + "authorization": "Bearer test", + } + ), + json={"test_param": "test_value"}, + timeout=25, + ) + + assert response == expected_response diff --git a/tests/test_directory_sync.py b/tests/test_directory_sync.py index 3f391b27..3afd73e0 100644 --- a/tests/test_directory_sync.py +++ b/tests/test_directory_sync.py @@ -1,13 +1,10 @@ import pytest -import requests -from tests.conftest import MockResponse, mock_pagination_request + from tests.utils.list_resource import list_data_to_dicts, list_response_of from workos.directory_sync import DirectorySync -from workos.resources.directory_sync import DirectoryUser from tests.utils.fixtures.mock_directory import MockDirectory from tests.utils.fixtures.mock_directory_user import MockDirectoryUser from tests.utils.fixtures.mock_directory_group import MockDirectoryGroup -from workos.resources.list import WorkOsListResource class TestDirectorySync(object): diff --git a/tests/test_events.py b/tests/test_events.py index 8805517d..56ab3b57 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -1,43 +1,60 @@ import pytest -from workos.events import Events + from tests.utils.fixtures.mock_event import MockEvent +from workos.events import Events +from workos.utils.http_client import SyncHTTPClient class TestEvents(object): @pytest.fixture(autouse=True) - def setup(self, set_api_key, set_client_id): - self.events = Events() + def setup( + self, + set_api_key, + set_client_id, + ): + self.http_client = SyncHTTPClient( + base_url="https://api.workos.test", version="test" + ) + self.events = Events(http_client=self.http_client) @pytest.fixture def mock_events(self): - events = [MockEvent(id=str(i)).to_dict() for i in range(100)] + events = [MockEvent(id=str(i)).to_dict() for i in range(10)] return { "data": events, - "list_metadata": {"after": None}, "metadata": { "params": { - "events": None, - "limit": None, + "events": ["dsync.user.created"], + "limit": 10, "organization_id": None, "after": None, "range_start": None, "range_end": None, - "default_limit": True, }, "method": Events.list_events, }, } - def test_list_events(self, mock_events, mock_request_method): - mock_request_method("get", mock_events, 200) + def test_list_events(self, mock_events, mock_sync_http_client_with_response): + mock_sync_http_client_with_response( + http_client=self.http_client, + status_code=200, + response_dict={"data": mock_events["data"]}, + ) - events = self.events.list_events() + events = self.events.list_events(events=["dsync.user.created"]) assert events == mock_events - def test_list_events_returns_metadata(self, mock_events, mock_request_method): - mock_request_method("get", mock_events, 200) + def test_list_events_returns_metadata( + self, mock_events, mock_sync_http_client_with_response + ): + mock_sync_http_client_with_response( + http_client=self.http_client, + status_code=200, + response_dict={"data": mock_events["data"]}, + ) events = self.events.list_events( events=["dsync.user.created"], @@ -46,9 +63,13 @@ def test_list_events_returns_metadata(self, mock_events, mock_request_method): assert events["metadata"]["params"]["events"] == ["dsync.user.created"] def test_list_events_with_organization_id_returns_metadata( - self, mock_events, mock_request_method + self, mock_events, mock_sync_http_client_with_response ): - mock_request_method("get", mock_events, 200) + mock_sync_http_client_with_response( + http_client=self.http_client, + status_code=200, + response_dict={"data": mock_events["data"]}, + ) events = self.events.list_events( events=["dsync.user.created"], diff --git a/tests/test_sync_http_client.py b/tests/test_sync_http_client.py new file mode 100644 index 00000000..faaca2d5 --- /dev/null +++ b/tests/test_sync_http_client.py @@ -0,0 +1,105 @@ +from platform import python_version + +import httpx +import pytest +from unittest.mock import MagicMock + +from workos.utils.http_client import SyncHTTPClient + + +class TestSyncHTTPClient(object): + @pytest.fixture(autouse=True) + def setup(self): + response = httpx.Response(200, json={"message": "Success!"}) + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"message": "Success!"}) + + self.http_client = SyncHTTPClient( + base_url="https://api.workos.test", + version="test", + transport=httpx.MockTransport(handler), + ) + + self.http_client._client.request = MagicMock( + return_value=response, + ) + + @pytest.mark.parametrize( + "method,status_code,expected_response", + [ + ("GET", 200, {"message": "Success!"}), + ("DELETE", 204, None), + ("DELETE", 202, None), + ], + ) + def test_request_without_body( + self, method: str, status_code: int, expected_response: dict + ): + self.http_client._client.request = MagicMock( + return_value=httpx.Response( + status_code=status_code, json=expected_response + ), + ) + + response = self.http_client.request( + "events", + method=method, + params={"test_param": "test_value"}, + token="test", + ) + + self.http_client._client.request.assert_called_with( + method=method, + url="https://api.workos.test/events", + headers=httpx.Headers( + { + "accept": "application/json", + "content-type": "application/json", + "user-agent": f"WorkOS Python/{python_version()} Python SDK/test", + "authorization": "Bearer test", + } + ), + params={"test_param": "test_value"}, + timeout=25, + ) + + assert response == expected_response + + @pytest.mark.parametrize( + "method,status_code,expected_response", + [ + ("POST", 201, {"message": "Success!"}), + ("PUT", 200, {"message": "Success!"}), + ("PATCH", 200, {"message": "Success!"}), + ], + ) + def test_request_with_body( + self, method: str, status_code: int, expected_response: dict + ): + self.http_client._client.request = MagicMock( + return_value=httpx.Response( + status_code=status_code, json=expected_response + ), + ) + + response = self.http_client.request( + "events", method=method, params={"test_param": "test_value"}, token="test" + ) + + self.http_client._client.request.assert_called_with( + method=method, + url="https://api.workos.test/events", + headers=httpx.Headers( + { + "accept": "application/json", + "content-type": "application/json", + "user-agent": f"WorkOS Python/{python_version()} Python SDK/test", + "authorization": "Bearer test", + } + ), + json={"test_param": "test_value"}, + timeout=25, + ) + + assert response == expected_response diff --git a/tests/utils/fixtures/mock_event.py b/tests/utils/fixtures/mock_event.py index 0acdda4d..2992d398 100644 --- a/tests/utils/fixtures/mock_event.py +++ b/tests/utils/fixtures/mock_event.py @@ -8,7 +8,7 @@ def __init__(self, id): self.id = id self.event = "dsync.user.created" self.data = {"id": "event_01234ABCD", "organization_id": "org_1234"} - self.created_at = datetime.datetime.now() + self.created_at = datetime.datetime.now().isoformat() OBJECT_FIELDS = [ "object", diff --git a/workos/__init__.py b/workos/__init__.py index 2c29f6a8..c149cd6f 100644 --- a/workos/__init__.py +++ b/workos/__init__.py @@ -1,9 +1,16 @@ import os from workos.__about__ import __version__ -from workos.client import client +from workos.client import SyncClient +from workos.async_client import AsyncClient api_key = os.getenv("WORKOS_API_KEY") client_id = os.getenv("WORKOS_CLIENT_ID") -base_api_url = "https://api.workos.com/" -request_timeout = 25 +base_api_url = os.getenv("WORKOS_BASE_URL", "https://api.workos.com/") +request_timeout = int(os.getenv("WORKOS_REQUEST_TIMEOUT", "25")) + + +client = SyncClient(base_url=base_api_url, version=__version__, timeout=request_timeout) +async_client = AsyncClient( + base_url=base_api_url, version=__version__, timeout=request_timeout +) diff --git a/workos/_base_client.py b/workos/_base_client.py new file mode 100644 index 00000000..7d2b1757 --- /dev/null +++ b/workos/_base_client.py @@ -0,0 +1,59 @@ +from typing import Protocol + +from workos.utils.http_client import BaseHTTPClient +from workos.audit_logs import AuditLogsModule +from workos.directory_sync import DirectorySyncModule +from workos.events import EventsModule +from workos.mfa import MFAModule +from workos.organizations import OrganizationsModule +from workos.passwordless import PasswordlessModule +from workos.portal import PortalModule +from workos.sso import SSOModule +from workos.user_management import UserManagementModule +from workos.webhooks import WebhooksModule + + +class BaseClient(Protocol): + """Base client for accessing the WorkOS feature set.""" + + _http_client: BaseHTTPClient + + @property + def audit_logs(self) -> AuditLogsModule: + ... + + @property + def directory_sync(self) -> DirectorySyncModule: + ... + + @property + def events(self) -> EventsModule: + ... + + @property + def mfa(self) -> MFAModule: + ... + + @property + def organizations(self) -> OrganizationsModule: + ... + + @property + def passwordless(self) -> PasswordlessModule: + ... + + @property + def portal(self) -> PortalModule: + ... + + @property + def sso(self) -> SSOModule: + ... + + @property + def user_management(self) -> UserManagementModule: + ... + + @property + def webhooks(self) -> WebhooksModule: + ... diff --git a/workos/async_client.py b/workos/async_client.py new file mode 100644 index 00000000..513ad711 --- /dev/null +++ b/workos/async_client.py @@ -0,0 +1,68 @@ +from workos._base_client import BaseClient +from workos.events import AsyncEvents +from workos.utils.http_client import AsyncHTTPClient + + +class AsyncClient(BaseClient): + """Client for a convenient way to access the WorkOS feature set.""" + + _http_client: AsyncHTTPClient + + def __init__(self, base_url: str, version: str, timeout: int): + self._http_client = AsyncHTTPClient( + base_url=base_url, version=version, timeout=timeout + ) + + @property + def sso(self): + raise NotImplementedError("SSO APIs are not yet supported in the async client.") + + @property + def audit_logs(self): + raise NotImplementedError( + "Audit logs APIs are not yet supported in the async client." + ) + + @property + def directory_sync(self): + raise NotImplementedError( + "Directory Sync APIs are not yet supported in the async client." + ) + + @property + def events(self): + if not getattr(self, "_events", None): + self._events = AsyncEvents(self._http_client) + return self._events + + @property + def organizations(self): + raise NotImplementedError( + "Organizations APIs are not yet supported in the async client." + ) + + @property + def passwordless(self): + raise NotImplementedError( + "Passwordless APIs are not yet supported in the async client." + ) + + @property + def portal(self): + raise NotImplementedError( + "Portal APIs are not yet supported in the async client." + ) + + @property + def webhooks(self): + raise NotImplementedError("Webhooks are not yet supported in the async client.") + + @property + def mfa(self): + raise NotImplementedError("MFA APIs are not yet supported in the async client.") + + @property + def user_management(self): + raise NotImplementedError( + "User Management APIs are not yet supported in the async client." + ) diff --git a/workos/audit_logs.py b/workos/audit_logs.py index ec91d2d1..92ef9473 100644 --- a/workos/audit_logs.py +++ b/workos/audit_logs.py @@ -1,4 +1,6 @@ +from typing import Optional, Protocol from warnings import warn + import workos from workos.resources.audit_logs_export import WorkOSAuditLogExport from workos.utils.request import RequestHelper, REQUEST_METHOD_GET, REQUEST_METHOD_POST @@ -8,7 +10,30 @@ EXPORTS_PATH = "audit_logs/exports" -class AuditLogs(object): +class AuditLogsModule(Protocol): + def create_event( + self, organization: str, event: dict, idempotency_key: Optional[str] = None + ) -> None: + ... + + def create_export( + self, + organization, + range_start, + range_end, + actions=None, + actors=None, + targets=None, + actor_names=None, + actor_ids=None, + ) -> WorkOSAuditLogExport: + ... + + def get_export(self, export_id) -> WorkOSAuditLogExport: + ... + + +class AuditLogs(AuditLogsModule): """Offers methods through the WorkOS Audit Logs service.""" @validate_settings(AUDIT_LOGS_MODULE) @@ -21,7 +46,9 @@ def request_helper(self): self._request_helper = RequestHelper() return self._request_helper - def create_event(self, organization, event, idempotency_key=None): + def create_event( + self, organization: str, event: dict, idempotency_key: Optional[str] = None + ): """Create an Audit Logs event. Args: diff --git a/workos/client.py b/workos/client.py index 30e02ed2..a2ae7748 100644 --- a/workos/client.py +++ b/workos/client.py @@ -1,3 +1,4 @@ +from workos._base_client import BaseClient from workos.audit_logs import AuditLogs from workos.directory_sync import DirectorySync from workos.organizations import Organizations @@ -8,11 +9,19 @@ from workos.mfa import Mfa from workos.events import Events from workos.user_management import UserManagement +from workos.utils.http_client import SyncHTTPClient -class Client(object): +class SyncClient(BaseClient): """Client for a convenient way to access the WorkOS feature set.""" + _http_client: SyncHTTPClient + + def __init__(self, base_url: str, version: str, timeout: int): + self._http_client = SyncHTTPClient( + base_url=base_url, version=version, timeout=timeout + ) + @property def sso(self): if not getattr(self, "_sso", None): @@ -34,7 +43,7 @@ def directory_sync(self): @property def events(self): if not getattr(self, "_events", None): - self._events = Events() + self._events = Events(self._http_client) return self._events @property @@ -72,6 +81,3 @@ def user_management(self): if not getattr(self, "_user_management", None): self._user_management = UserManagement() return self._user_management - - -client = Client() diff --git a/workos/directory_sync.py b/workos/directory_sync.py index 6f06127d..4e0efc4a 100644 --- a/workos/directory_sync.py +++ b/workos/directory_sync.py @@ -1,4 +1,5 @@ -from typing import Optional +from typing import Optional, Protocol + import workos from workos.utils.pagination_order import PaginationOrder from workos.utils.request import ( @@ -19,7 +20,55 @@ RESPONSE_LIMIT = 10 -class DirectorySync: +class DirectorySyncModule(Protocol): + def list_users( + self, + directory: Optional[str] = None, + group: Optional[str] = None, + limit: int = RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> WorkOsListResource[DirectoryUser]: + ... + + def list_groups( + self, + directory: Optional[str] = None, + user: Optional[str] = None, + limit: int = RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> WorkOsListResource[DirectoryGroup]: + ... + + def get_user(self, user: str) -> DirectoryUser: + ... + + def get_group(self, group: str) -> DirectoryGroup: + ... + + def get_directory(self, directory: str) -> Directory: + ... + + def list_directories( + self, + domain: Optional[str] = None, + search: Optional[str] = None, + limit: int = RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + organization: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> WorkOsListResource[Directory]: + ... + + def delete_directory(self, directory: str) -> None: + ... + + +class DirectorySync(DirectorySyncModule): """Offers methods through the WorkOS Directory Sync service.""" @validate_settings(DIRECTORY_SYNC_MODULE) diff --git a/workos/events.py b/workos/events.py index d4aef529..d612b25d 100644 --- a/workos/events.py +++ b/workos/events.py @@ -1,38 +1,49 @@ -from warnings import warn +from typing import Awaitable, List, Optional, Protocol, Union + import workos from workos.utils.request import ( - RequestHelper, REQUEST_METHOD_GET, ) - +from workos.utils.http_client import AsyncHTTPClient, SyncHTTPClient from workos.utils.validation import EVENTS_MODULE, validate_settings from workos.resources.list import WorkOSListResource RESPONSE_LIMIT = 10 -class Events(WorkOSListResource): +class EventsModule(Protocol): + def list_events( + self, + # TODO: Use event Literal type when available + events: List[str], + limit: Optional[int] = None, + organization_id: Optional[str] = None, + after: Optional[str] = None, + range_start: Optional[str] = None, + range_end: Optional[str] = None, + ) -> Union[dict, Awaitable[dict]]: + ... + + +class Events(EventsModule, WorkOSListResource): """Offers methods through the WorkOS Events service.""" - @validate_settings(EVENTS_MODULE) - def __init__(self): - pass + _http_client: SyncHTTPClient - @property - def request_helper(self): - if not getattr(self, "_request_helper", None): - self._request_helper = RequestHelper() - return self._request_helper + @validate_settings(EVENTS_MODULE) + def __init__(self, http_client: SyncHTTPClient): + self._http_client = http_client def list_events( self, - events=None, + # TODO: Use event Literal type when available + events: List[str], limit=None, organization_id=None, after=None, range_start=None, range_end=None, - ): + ) -> dict: """Gets a list of Events . Kwargs: events (list): Filter to only return events of particular types. (Optional) @@ -60,7 +71,7 @@ def list_events( "range_end": range_end, } - response = self.request_helper.request( + response = self._http_client.request( "events", method=REQUEST_METHOD_GET, params=params, @@ -79,3 +90,70 @@ def list_events( } return response + + +class AsyncEvents(EventsModule, WorkOSListResource): + """Offers methods through the WorkOS Events service.""" + + _http_client: AsyncHTTPClient + + @validate_settings(EVENTS_MODULE) + def __init__(self, http_client: AsyncHTTPClient): + self._http_client = http_client + + async def list_events( + self, + # TODO: Use event Literal type when available + events: List[str], + limit: Optional[int] = None, + organization_id: Optional[str] = None, + after: Optional[str] = None, + range_start: Optional[str] = None, + range_end: Optional[str] = None, + ) -> dict: + """Gets a list of Events . + Kwargs: + events (list): Filter to only return events of particular types. (Optional) + limit (int): Maximum number of records to return. (Optional) + organization_id(str): Organization ID limits scope of events to a single organization. (Optional) + after (str): Pagination cursor to receive records after a provided Event ID. (Optional) + range_start (str): Date range start for stream of events. (Optional) + range_end (str): Date range end for stream of events. (Optional) + + + Returns: + dict: Events response from WorkOS. + """ + + if limit is None: + limit = RESPONSE_LIMIT + default_limit = True + + params = { + "events": events, + "limit": limit, + "after": after, + "organization_id": organization_id, + "range_start": range_start, + "range_end": range_end, + } + + response = await self._http_client.request( + "events", + method=REQUEST_METHOD_GET, + params=params, + token=workos.api_key, + ) + + if "default_limit" in locals(): + if "metadata" in response and "params" in response["metadata"]: + response["metadata"]["params"]["default_limit"] = default_limit + else: + response["metadata"] = {"params": {"default_limit": default_limit}} + + response["metadata"] = { + "params": params, + "method": AsyncEvents.list_events, + } + + return response diff --git a/workos/mfa.py b/workos/mfa.py index bc3c0f07..18e5c763 100644 --- a/workos/mfa.py +++ b/workos/mfa.py @@ -1,4 +1,6 @@ +from typing import Protocol from warnings import warn + import workos from workos.utils.request import ( RequestHelper, @@ -15,7 +17,35 @@ ) -class Mfa(object): +class MFAModule(Protocol): + def enroll_factor( + self, + type=None, + totp_issuer=None, + totp_user=None, + phone_number=None, + ) -> dict: + ... + + def get_factor(self, authentication_factor_id=None) -> dict: + ... + + def delete_factor(self, authentication_factor_id=None) -> None: + ... + + def challenge_factor( + self, authentication_factor_id=None, sms_template=None + ) -> dict: + ... + + def verify_factor(self, authentication_challenge_id=None, code=None) -> dict: + ... + + def verify_challenge(self, authentication_challenge_id=None, code=None) -> dict: + ... + + +class Mfa(MFAModule): """Methods to assist in creating, challenging, and verifying Authentication Factors through the WorkOS MFA service.""" @validate_settings(MFA_MODULE) diff --git a/workos/organizations.py b/workos/organizations.py index 5360899f..5bf2c5bc 100644 --- a/workos/organizations.py +++ b/workos/organizations.py @@ -1,4 +1,5 @@ -from typing import List, Optional +from typing import List, Optional, Protocol + import workos from workos.utils.pagination_order import PaginationOrder from workos.utils.request import ( @@ -19,7 +20,44 @@ RESPONSE_LIMIT = 10 -class Organizations: +class OrganizationsModule(Protocol): + def list_organizations( + self, + domains: Optional[List[str]] = None, + limit: int = RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> WorkOsListResource[Organization]: + ... + + def get_organization(self, organization: str) -> Organization: + ... + + def get_organization_by_lookup_key(self, lookup_key: str) -> Organization: + ... + + def create_organization( + self, + name: str, + domain_data: Optional[List[DomainDataInput]] = None, + idempotency_key: Optional[str] = None, + ) -> Organization: + ... + + def update_organization( + self, + organization: str, + name: str, + domain_data: Optional[List[DomainDataInput]] = None, + ) -> Organization: + ... + + def delete_organization(self, organization: str) -> None: + ... + + +class Organizations(OrganizationsModule): @validate_settings(ORGANIZATIONS_MODULE) def __init__(self): pass diff --git a/workos/passwordless.py b/workos/passwordless.py index 008c471a..5a4c186a 100644 --- a/workos/passwordless.py +++ b/workos/passwordless.py @@ -1,10 +1,20 @@ +from typing import Literal, Protocol + import workos from workos.utils.request import RequestHelper, REQUEST_METHOD_POST from workos.utils.validation import PASSWORDLESS_MODULE, validate_settings from workos.resources.passwordless import WorkOSPasswordlessSession -class Passwordless(object): +class PasswordlessModule(Protocol): + def create_session(self, session_options: dict) -> dict: + ... + + def send_session(self, session_id: str) -> Literal[True]: + ... + + +class Passwordless(PasswordlessModule): """Offers methods through the WorkOS Passwordless service.""" @validate_settings(PASSWORDLESS_MODULE) @@ -49,7 +59,7 @@ def create_session(self, session_options): return WorkOSPasswordlessSession.construct_from_response(response).to_dict() - def send_session(self, session_id): + def send_session(self, session_id: str) -> Literal[True]: """Send a Passwordless Session via email. Args: diff --git a/workos/portal.py b/workos/portal.py index 4c02bc3e..a7cac88c 100644 --- a/workos/portal.py +++ b/workos/portal.py @@ -1,3 +1,5 @@ +from typing import Literal, Optional, Protocol + import workos from workos.utils.request import RequestHelper, REQUEST_METHOD_POST from workos.utils.validation import PORTAL_MODULE, validate_settings @@ -6,7 +8,18 @@ PORTAL_GENERATE_PATH = "portal/generate_link" -class Portal(object): +class PortalModule(Protocol): + def generate_link( + self, + intent: Literal["audit_logs", "dsync", "log_streams", "sso"], + organization: str, + return_url: Optional[str] = None, + success_url: Optional[str] = None, + ) -> dict: + ... + + +class Portal(PortalModule): @validate_settings(PORTAL_MODULE) def __init__(self): pass @@ -17,7 +30,13 @@ def request_helper(self): self._request_helper = RequestHelper() return self._request_helper - def generate_link(self, intent, organization, return_url=None, success_url=None): + def generate_link( + self, + intent: Literal["audit_logs", "dsync", "log_streams", "sso"], + organization: str, + return_url: Optional[str] = None, + success_url: Optional[str] = None, + ): """Generate a link to grant access to an organization's Admin Portal Args: diff --git a/workos/sso.py b/workos/sso.py index cff8d9e3..c089a607 100644 --- a/workos/sso.py +++ b/workos/sso.py @@ -1,5 +1,7 @@ -from requests import Request +from typing import Protocol from warnings import warn + +from requests import Request import workos from workos.utils.pagination_order import Order from workos.resources.sso import ( @@ -28,7 +30,58 @@ RESPONSE_LIMIT = 10 -class SSO(WorkOSListResource): +class SSOModule(Protocol): + def get_authorization_url( + self, + domain=None, + domain_hint=None, + login_hint=None, + redirect_uri=None, + state=None, + provider=None, + connection=None, + organization=None, + ) -> str: + ... + + def get_profile(self, accessToken: str) -> WorkOSProfile: + ... + + def get_profile_and_token(self, code: str) -> WorkOSProfileAndToken: + ... + + def get_connection(self, connection: str) -> dict: + ... + + def list_connections( + self, + connection_type=None, + domain=None, + organization_id=None, + limit=None, + before=None, + after=None, + order=None, + ) -> dict: + ... + + def list_connections_v2( + self, + connection_type=None, + domain=None, + organization_id=None, + limit=None, + before=None, + after=None, + order=None, + ) -> dict: + ... + + def delete_connection(self, connection: str) -> None: + ... + + +class SSO(SSOModule, WorkOSListResource): """Offers methods to assist in authenticating through the WorkOS SSO service.""" @validate_settings(SSO_MODULE) diff --git a/workos/user_management.py b/workos/user_management.py index 861718ed..8124816f 100644 --- a/workos/user_management.py +++ b/workos/user_management.py @@ -1,5 +1,7 @@ -from requests import Request +from typing import Protocol from warnings import warn + +from requests import Request import workos from workos.resources.list import WorkOSListResource from workos.resources.mfa import WorkOSAuthenticationFactorTotp, WorkOSChallenge @@ -57,7 +59,220 @@ RESPONSE_LIMIT = 10 -class UserManagement(WorkOSListResource): +class UserManagementModule(Protocol): + def get_user(self, user_id: str) -> dict: + ... + + def list_users( + self, + email=None, + organization_id=None, + limit=None, + before=None, + after=None, + order=None, + ) -> dict: + ... + + def create_user(self, user: dict) -> dict: + ... + + def update_user(self, user_id: str, payload: dict) -> dict: + ... + + def delete_user(self, user_id: str) -> None: + ... + + def create_organization_membership( + self, user_id: str, organization_id: str, role_slug=None + ) -> dict: + ... + + def update_organization_membership( + self, organization_membership_id: str, role_slug=None + ) -> dict: + ... + + def get_organization_membership(self, organization_membership_id: str) -> dict: + ... + + def list_organization_memberships( + self, + user_id=None, + organization_id=None, + statuses=None, + limit=None, + before=None, + after=None, + order=None, + ) -> dict: + ... + + def delete_organization_membership(self, organization_membership_id: str) -> None: + ... + + def deactivate_organization_membership( + self, organization_membership_id: str + ) -> dict: + ... + + def reactivate_organization_membership( + self, organization_membership_id: str + ) -> dict: + ... + + def get_authorization_url( + self, + redirect_uri: str, + connection_id=None, + organization_id=None, + provider=None, + domain_hint=None, + login_hint=None, + state=None, + code_challenge=None, + ) -> str: + ... + + def authenticate_with_password( + self, email: str, password: str, ip_address=None, user_agent=None + ) -> dict: + ... + + def authenticate_with_code( + self, code: str, code_verifier=None, ip_address=None, user_agent=None + ) -> dict: + ... + + def authenticate_with_magic_auth( + self, + code: str, + email: str, + link_authorization_code=None, + ip_address=None, + user_agent=None, + ) -> dict: + ... + + def authenticate_with_email_verification( + self, + code: str, + pending_authentication_token: str, + ip_address=None, + user_agent=None, + ) -> dict: + ... + + def authenticate_with_totp( + self, + code: str, + authentication_challenge_id: str, + pending_authentication_token: str, + ip_address=None, + user_agent=None, + ) -> dict: + ... + + def authenticate_with_organization_selection( + self, + organization_id, + pending_authentication_token, + ip_address=None, + user_agent=None, + ) -> dict: + ... + + def authenticate_with_refresh_token( + self, + refresh_token, + ip_address=None, + user_agent=None, + ) -> dict: + ... + + # TODO: Methods that don't method network requests can just be defined in the base class + def get_jwks_url(self) -> str: + ... + + # TODO: Methods that don't method network requests can just be defined in the base class + def get_logout_url(self, session_id) -> str: + ... + + def get_password_reset(self, password_reset_id) -> dict: + ... + + def create_password_reset(self, email) -> dict: + ... + + def send_password_reset_email(self, email, password_reset_url) -> None: + ... + + def reset_password(self, token, new_password) -> dict: + ... + + def get_email_verification(self, email_verification_id) -> dict: + ... + + def send_verification_email(self, user_id) -> dict: + ... + + def verify_email(self, user_id, code) -> dict: + ... + + def get_magic_auth(self, magic_auth_id) -> dict: + ... + + def create_magic_auth(self, email, invitation_token=None) -> dict: + ... + + def send_magic_auth_code(self, email) -> None: + ... + + def enroll_auth_factor( + self, + user_id, + type, + totp_issuer=None, + totp_user=None, + totp_secret=None, + ) -> dict: + ... + + def list_auth_factors(self, user_id) -> WorkOSListResource: + ... + + def get_invitation(self, invitation_id) -> dict: + ... + + def find_invitation_by_token(self, invitation_token) -> dict: + ... + + def list_invitations( + self, + email=None, + organization_id=None, + limit=None, + before=None, + after=None, + order=None, + ) -> WorkOSListResource: + ... + + def send_invitation( + self, + email, + organization_id=None, + expires_in_days=None, + inviter_user_id=None, + role_slug=None, + ) -> dict: + ... + + def revoke_invitation(self, invitation_id) -> dict: + ... + + +class UserManagement(UserManagementModule, WorkOSListResource): """Offers methods for using the WorkOS User Management API.""" @validate_settings(USER_MANAGEMENT_MODULE) diff --git a/workos/utils/_base_http_client.py b/workos/utils/_base_http_client.py new file mode 100644 index 00000000..aab0f7d6 --- /dev/null +++ b/workos/utils/_base_http_client.py @@ -0,0 +1,207 @@ +import platform +from typing import ( + cast, + Dict, + Generic, + Optional, + TypeVar, + TypedDict, + Union, +) +from typing_extensions import NotRequired + +import httpx + +from workos.exceptions import ( + ServerException, + AuthenticationException, + AuthorizationException, + NotFoundException, + BadRequestException, +) +from workos.utils.request import REQUEST_METHOD_DELETE, REQUEST_METHOD_GET + + +_HttpxClientT = TypeVar("_HttpxClientT", bound=Union[httpx.Client, httpx.AsyncClient]) + + +DEFAULT_REQUEST_TIMEOUT = 25 + + +class PreparedRequest(TypedDict): + method: str + url: str + headers: httpx.Headers + params: NotRequired[Union[Dict, None]] + json: NotRequired[Union[Dict, None]] + timeout: int + + +class BaseHTTPClient(Generic[_HttpxClientT]): + _client: _HttpxClientT + _base_url: str + _version: str + _timeout: int + + def __init__( + self, + *, + base_url: str, + version: str, + timeout: Optional[int] = DEFAULT_REQUEST_TIMEOUT, + ) -> None: + self.base_url = base_url + self._version = version + self._timeout = DEFAULT_REQUEST_TIMEOUT if timeout is None else timeout + + def _enforce_trailing_slash(self, url: str) -> str: + return url if url.endswith("/") else url + "/" + + def _generate_api_url(self, path: str) -> str: + return self._base_url.format(path) + + def _build_headers( + self, custom_headers: Union[dict, None], token: Optional[str] = None + ) -> httpx.Headers: + if custom_headers is None: + custom_headers = {} + + if token: + custom_headers["Authorization"] = "Bearer {}".format(token) + + # httpx.Headers is case-insensitive while dictionaries are not. + return httpx.Headers({**self.default_headers, **custom_headers}) + + def _maybe_raise_error_by_status_code( + self, response: httpx.Response, response_json: Union[dict, None] + ) -> None: + status_code = response.status_code + if status_code >= 400 and status_code < 500: + if status_code == 401: + raise AuthenticationException(response) + elif status_code == 403: + raise AuthorizationException(response) + elif status_code == 404: + raise NotFoundException(response) + + error = ( + response_json.get("error") + if response_json and "error" in response_json + else "Unknown" + ) + error_description = ( + response_json.get("error_description") + if response_json and "error_description" in response_json + else "Unknown" + ) + raise BadRequestException( + response, error=error, error_description=error_description + ) + elif status_code >= 500 and status_code < 600: + raise ServerException(response) + + def _prepare_request( + self, + path: str, + method: Optional[str] = REQUEST_METHOD_GET, + params: Optional[dict] = None, + headers: Optional[dict] = None, + token: Optional[str] = None, + ) -> PreparedRequest: + """Executes a request against the WorkOS API. + + Args: + path (str): Path for the api request that'd be appended to the base API URL + + Kwargs: + method Optional[str]: One of the supported methods as defined by the REQUEST_METHOD_X constants + params Optional[dict]: Query params or body payload to be added to the request + headers Optional[dict]: Custom headers to be added to the request + token Optional[str]: Bearer token + + Returns: + dict: Response from WorkOS + """ + url = self._generate_api_url(path) + parsed_headers = self._build_headers(headers, token) + parsed_method = REQUEST_METHOD_GET if method is None else method + bodyless_http_method = parsed_method.lower() in [ + REQUEST_METHOD_DELETE, + REQUEST_METHOD_GET, + ] + + # Remove any parameters that are None + if params is not None: + params = ( + {k: v for k, v in params.items() if v is not None} + if bodyless_http_method + else params + ) + + # We'll spread these return values onto the HTTP client request method + if bodyless_http_method: + return { + "method": parsed_method, + "url": url, + "headers": parsed_headers, + "params": params, + "timeout": self.timeout, + } + else: + return { + "method": parsed_method, + "url": url, + "headers": parsed_headers, + "json": params, + "timeout": self.timeout, + } + + def _handle_response(self, response: httpx.Response) -> dict: + response_json = None + content_type = ( + response.headers.get("content-type") + if response.headers is not None + else None + ) + if content_type is not None and "application/json" in content_type: + try: + response_json = response.json() + except ValueError: + raise ServerException(response) + + # type: ignore + self._maybe_raise_error_by_status_code(response, response_json) + + return cast(Dict, response_json) + + @property + def base_url(self) -> str: + return self._base_url + + @base_url.setter + def base_url(self, url: str) -> None: + """Creates an accessible template for constructing the URL for an API request. + + Args: + base_api_url (str): Base URL for api requests + """ + self._base_url = "{}{{}}".format(self._enforce_trailing_slash(url)) + + @property + def default_headers(self) -> Dict[str, str]: + return { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": self.user_agent, + } + + @property + def user_agent(self) -> str: + return "WorkOS Python/{} Python SDK/{}".format( + platform.python_version(), + self._version, + ) + + @property + def timeout(self) -> int: + return self._timeout diff --git a/workos/utils/http_client.py b/workos/utils/http_client.py new file mode 100644 index 00000000..d89f324f --- /dev/null +++ b/workos/utils/http_client.py @@ -0,0 +1,174 @@ +import asyncio +from typing import Awaitable, Optional + +import httpx + +from workos.utils._base_http_client import BaseHTTPClient +from workos.utils.request import REQUEST_METHOD_GET + + +class SyncHttpxClientWrapper(httpx.Client): + def __del__(self) -> None: + try: + self.close() + except Exception: + pass + + +class SyncHTTPClient(BaseHTTPClient[httpx.Client]): + """Sync HTTP Client for a convenient way to access the WorkOS feature set.""" + + _client: httpx.Client + + def __init__( + self, + *, + base_url: str, + version: str, + timeout: Optional[int] = None, + transport: Optional[httpx.BaseTransport] = httpx.HTTPTransport(), + ) -> None: + super().__init__( + base_url=base_url, + version=version, + timeout=timeout, + ) + self._client = SyncHttpxClientWrapper( + base_url=base_url, + timeout=timeout, + follow_redirects=True, + transport=transport, + ) + + def is_closed(self) -> bool: + return self._client.is_closed + + def close(self) -> None: + """Close the underlying HTTPX client. + + The client will *not* be usable after this. + """ + # If an error is thrown while constructing a client, self._client + # may not be present + if hasattr(self, "_client"): + self._client.close() + + def __enter__(self): + return self + + def __exit__( + self, + exc_type, + exc, + exc_tb, + ) -> None: + self.close() + + def request( + self, + path: str, + method: Optional[str] = REQUEST_METHOD_GET, + params: Optional[dict] = None, + headers: Optional[dict] = None, + token: Optional[str] = None, + ) -> dict: + """Executes a request against the WorkOS API. + + Args: + path (str): Path for the api request that'd be appended to the base API URL + + Kwargs: + method (str): One of the supported methods as defined by the REQUEST_METHOD_X constants + params (dict): Query params or body payload to be added to the request + token (str): Bearer token + + Returns: + dict: Response from WorkOS + """ + prepared_request_params = self._prepare_request( + path=path, method=method, params=params, headers=headers, token=token + ) + response = self._client.request(**prepared_request_params) + return self._handle_response(response) + + +class AsyncHttpxClientWrapper(httpx.AsyncClient): + def __del__(self) -> None: + try: + asyncio.get_running_loop().create_task(self.aclose()) + except Exception: + pass + + +class AsyncHTTPClient(BaseHTTPClient[httpx.AsyncClient]): + """Async HTTP Client for a convenient way to access the WorkOS feature set.""" + + _client: httpx.AsyncClient + + def __init__( + self, + *, + base_url: str, + version: str, + timeout: Optional[int] = None, + transport: Optional[httpx.AsyncBaseTransport] = httpx.AsyncHTTPTransport(), + ) -> None: + super().__init__( + base_url=base_url, + version=version, + timeout=timeout, + ) + self._client = AsyncHttpxClientWrapper( + base_url=base_url, + timeout=timeout, + follow_redirects=True, + transport=transport, + ) + + def is_closed(self) -> bool: + return self._client.is_closed + + async def close(self) -> None: + """Close the underlying HTTPX client. + + The client will *not* be usable after this. + """ + await self._client.aclose() + + async def __aenter__(self): + return self + + async def __aexit__( + self, + exc_type, + exc, + exc_tb, + ) -> None: + await self.close() + + async def request( + self, + path: str, + method: Optional[str] = REQUEST_METHOD_GET, + params: Optional[dict] = None, + headers: Optional[dict] = None, + token: Optional[str] = None, + ) -> dict: + """Executes a request against the WorkOS API. + + Args: + path (str): Path for the api request that'd be appended to the base API URL + + Kwargs: + method (str): One of the supported methods as defined by the REQUEST_METHOD_X constants + params (dict): Query params or body payload to be added to the request + token (str): Bearer token + + Returns: + dict: Response from WorkOS + """ + prepared_request_parameters = self._prepare_request( + path=path, method=method, params=params, headers=headers, token=token + ) + response = await self._client.request(**prepared_request_parameters) + return self._handle_response(response) diff --git a/workos/webhooks.py b/workos/webhooks.py index 2d26935e..3e98f9ba 100644 --- a/workos/webhooks.py +++ b/workos/webhooks.py @@ -1,3 +1,5 @@ +from typing import Protocol + from workos.utils.request import RequestHelper from workos.utils.validation import WEBHOOKS_MODULE, validate_settings import hmac @@ -7,7 +9,21 @@ import hashlib -class Webhooks(object): +class WebhooksModule(Protocol): + def verify_event(self, payload, sig_header, secret, tolerance) -> dict: + ... + + def verify_header(self, event_body, event_signature, secret, tolerance) -> None: + ... + + def constant_time_compare(self, val1, val2) -> bool: + ... + + def check_timestamp_range(self, time, max_range) -> None: + ... + + +class Webhooks(WebhooksModule): """Offers methods through the WorkOS Webhooks service.""" @validate_settings(WEBHOOKS_MODULE) From 313826402da7b77ba96d5a5f4bc7c53bda1d41de Mon Sep 17 00:00:00 2001 From: pantera Date: Tue, 23 Jul 2024 14:45:50 -0700 Subject: [PATCH 03/18] List typing fixes (#287) * Update ini to pick up more files * Remove runtime type checking for list types * Change approach to specifying list params * Fix after rebase * Formatting * Remove som unused imports and switch typed dict import to typing extensions --- mypy.ini | 2 +- tests/utils/list_resource.py | 3 +- workos/directory_sync.py | 74 ++++++++++++++++++++++-------------- workos/organizations.py | 20 +++++----- workos/resources/list.py | 54 +++++++++++++------------- 5 files changed, 86 insertions(+), 67 deletions(-) diff --git a/mypy.ini b/mypy.ini index 7fa1d2e9..5a47b116 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,2 +1,2 @@ [mypy] -files=./workos/resources/organizations.py,./workos/resources/directory_sync.py +files=./workos/**/*/organizations.py,./workos/**/*/directory_sync.py \ No newline at end of file diff --git a/tests/utils/list_resource.py b/tests/utils/list_resource.py index 390dea31..0cd8f94f 100644 --- a/tests/utils/list_resource.py +++ b/tests/utils/list_resource.py @@ -1,5 +1,4 @@ -from typing import Dict, List, Optional, TypeVar, TypedDict, Union -from workos.resources.list import ListPage, WorkOsListResource +from typing import Dict, List, Optional def list_data_to_dicts(list_data: List): diff --git a/workos/directory_sync.py b/workos/directory_sync.py index 4e0efc4a..5fb72ca8 100644 --- a/workos/directory_sync.py +++ b/workos/directory_sync.py @@ -1,5 +1,4 @@ from typing import Optional, Protocol - import workos from workos.utils.pagination_order import PaginationOrder from workos.utils.request import ( @@ -20,6 +19,25 @@ RESPONSE_LIMIT = 10 +class DirectoryListFilters(ListArgs, total=False): + search: Optional[str] + organization_id: Optional[str] + domain: Optional[str] + + +class DirectoryUserListFilters( + ListArgs, + total=False, +): + group: Optional[str] + directory: Optional[str] + + +class DirectoryGroupListFilters(ListArgs, total=False): + user: Optional[str] + directory: Optional[str] + + class DirectorySyncModule(Protocol): def list_users( self, @@ -29,7 +47,7 @@ def list_users( before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", - ) -> WorkOsListResource[DirectoryUser]: + ) -> WorkOsListResource[DirectoryUser, DirectoryUserListFilters]: ... def list_groups( @@ -40,7 +58,7 @@ def list_groups( before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", - ) -> WorkOsListResource[DirectoryGroup]: + ) -> WorkOsListResource[DirectoryGroup, DirectoryGroupListFilters]: ... def get_user(self, user: str) -> DirectoryUser: @@ -61,7 +79,7 @@ def list_directories( after: Optional[str] = None, organization: Optional[str] = None, order: PaginationOrder = "desc", - ) -> WorkOsListResource[Directory]: + ) -> WorkOsListResource[Directory, DirectoryListFilters]: ... def delete_directory(self, directory: str) -> None: @@ -89,7 +107,7 @@ def list_users( before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", - ) -> WorkOsListResource[DirectoryUser]: + ) -> WorkOsListResource[DirectoryUser, DirectoryUserListFilters]: """Gets a list of provisioned Users for a Directory. Note, either 'directory' or 'group' must be provided. @@ -106,7 +124,7 @@ def list_users( dict: Directory Users response from WorkOS. """ - params = { + list_params: DirectoryUserListFilters = { "limit": limit, "before": before, "after": after, @@ -114,22 +132,21 @@ def list_users( } if group is not None: - params["group"] = group + list_params["group"] = group if directory is not None: - params["directory"] = directory + list_params["directory"] = directory response = self.request_helper.request( "directory_users", method=REQUEST_METHOD_GET, - params=params, + params=list_params, token=workos.api_key, ) return WorkOsListResource( list_method=self.list_users, - # TODO: Should we even bother with this validation? - list_args=ListArgs.model_validate(params), - **ListPage[DirectoryUser](**response).model_dump() + list_args=list_params, + **ListPage[DirectoryUser](**response).model_dump(), ) def list_groups( @@ -140,7 +157,7 @@ def list_groups( before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", - ) -> WorkOsListResource[DirectoryGroup]: + ) -> WorkOsListResource[DirectoryGroup, DirectoryGroupListFilters]: """Gets a list of provisioned Groups for a Directory . Note, either 'directory' or 'user' must be provided. @@ -156,29 +173,29 @@ def list_groups( Returns: dict: Directory Groups response from WorkOS. """ - params = { + list_params: DirectoryGroupListFilters = { "limit": limit, "before": before, "after": after, "order": order, } + if user is not None: - params["user"] = user + list_params["user"] = user if directory is not None: - params["directory"] = directory + list_params["directory"] = directory response = self.request_helper.request( "directory_groups", method=REQUEST_METHOD_GET, - params=params, + params=list_params, token=workos.api_key, ) return WorkOsListResource( list_method=self.list_groups, - # TODO: Should we even bother with this validation? - list_args=ListArgs.model_validate(params), - **ListPage[DirectoryGroup](**response).model_dump() + list_args=list_params, + **ListPage[DirectoryGroup](**response).model_dump(), ) def get_user(self, user: str): @@ -242,7 +259,7 @@ def list_directories( after: Optional[str] = None, organization: Optional[str] = None, order: PaginationOrder = "desc", - ) -> WorkOsListResource[Directory]: + ) -> WorkOsListResource[Directory, DirectoryListFilters]: """Gets details for existing Directories. Args: @@ -258,27 +275,26 @@ def list_directories( dict: Directories response from WorkOS. """ - params = { - "domain": domain, - "organization": organization, - "search": search, + list_params: DirectoryListFilters = { "limit": limit, "before": before, "after": after, "order": order, + "domain": domain, + "organization_id": organization, + "search": search, } response = self.request_helper.request( "directories", method=REQUEST_METHOD_GET, - params=params, + params=list_params, token=workos.api_key, ) return WorkOsListResource( list_method=self.list_directories, - # TODO: Should we even bother with this validation? - list_args=ListArgs.model_validate(params), - **ListPage[Directory](**response).model_dump() + list_args=list_params, + **ListPage[Directory](**response).model_dump(), ) def delete_directory(self, directory: str): diff --git a/workos/organizations.py b/workos/organizations.py index 5bf2c5bc..92287a30 100644 --- a/workos/organizations.py +++ b/workos/organizations.py @@ -1,5 +1,4 @@ from typing import List, Optional, Protocol - import workos from workos.utils.pagination_order import PaginationOrder from workos.utils.request import ( @@ -20,6 +19,10 @@ RESPONSE_LIMIT = 10 +class OrganizationListFilters(ListArgs, total=False): + domains: Optional[List[str]] + + class OrganizationsModule(Protocol): def list_organizations( self, @@ -28,7 +31,7 @@ def list_organizations( before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", - ) -> WorkOsListResource[Organization]: + ) -> WorkOsListResource[Organization, OrganizationListFilters]: ... def get_organization(self, organization: str) -> Organization: @@ -75,7 +78,7 @@ def list_organizations( before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", - ) -> WorkOsListResource[Organization]: + ) -> WorkOsListResource[Organization, OrganizationListFilters]: """Retrieve a list of organizations that have connections configured within your WorkOS dashboard. Kwargs: @@ -89,25 +92,24 @@ def list_organizations( dict: Organizations response from WorkOS. """ - params = { - "domains": domains, + list_params: OrganizationListFilters = { "limit": limit, "before": before, "after": after, "order": order, + "domains": domains, } response = self.request_helper.request( ORGANIZATIONS_PATH, method=REQUEST_METHOD_GET, - params=params, + params=list_params, token=workos.api_key, ) - return WorkOsListResource[Organization]( + return WorkOsListResource[Organization, OrganizationListFilters]( list_method=self.list_organizations, - # TODO: Should we even bother with this validation? - list_args=ListArgs.model_validate(params), + list_args=list_params, **ListPage[Organization](**response).model_dump() ) diff --git a/workos/resources/list.py b/workos/resources/list.py index 6a479380..99ceda18 100644 --- a/workos/resources/list.py +++ b/workos/resources/list.py @@ -1,7 +1,5 @@ -from abc import abstractmethod from typing import ( List, - Any, Literal, TypeVar, Generic, @@ -9,17 +7,16 @@ Iterator, Optional, ) - +from typing_extensions import TypedDict from workos.resources.base import WorkOSBaseResource from workos.resources.directory_sync import Directory, DirectoryGroup, DirectoryUser from workos.resources.organizations import Organization - -from pydantic import BaseModel, Extra, Field - -# TODO: THIS OLD RESOURCE GOES AWAY +from pydantic import BaseModel, Field +from workos.resources.workos_model import WorkOSModel class WorkOSListResource(WorkOSBaseResource): + # TODO: THIS OLD RESOURCE GOES AWAY """Representation of a WorkOS List Resource as returned through the API. Attributes: @@ -124,44 +121,49 @@ class ListMetadata(BaseModel): before: Optional[str] = None -class ListPage(BaseModel, Generic[ListableResource]): +class ListPage(WorkOSModel, Generic[ListableResource]): object: Literal["list"] data: List[ListableResource] list_metadata: ListMetadata -class ListArgs(BaseModel, extra="allow"): - limit: Optional[int] = 10 - before: Optional[str] = None - after: Optional[str] = None - order: Literal["asc", "desc"] = "desc" +class ListArgs(TypedDict): + limit: int + before: Optional[str] + after: Optional[str] + order: Literal["asc", "desc"] + - class Config: - extra = "allow" +ListAndFilterParams = TypeVar("ListAndFilterParams", bound=ListArgs) -class WorkOsListResource(BaseModel, Generic[ListableResource]): +class WorkOsListResource( + WorkOSModel, + Generic[ListableResource, ListAndFilterParams], +): object: Literal["list"] data: List[ListableResource] list_metadata: ListMetadata - # These fields end up exposed in the types. Does we care? list_method: Callable = Field(exclude=True) - list_args: ListArgs = Field(exclude=True) + list_args: ListAndFilterParams = Field(exclude=True) def auto_paging_iter(self) -> Iterator[ListableResource]: - next_page: WorkOsListResource[ListableResource] + next_page: WorkOsListResource[ListableResource, ListAndFilterParams] after = self.list_metadata.after - order = self.list_args.order - - fixed_pagination_params = {"order": order, "limit": self.list_args.limit} - filter_params = self.list_args.model_dump( - exclude={"after", "before", "order", "limit"} - ) + fixed_pagination_params = { + "order": self.list_args["order"], + "limit": self.list_args["limit"], + } + # Omit common list parameters + filter_params = { + k: v + for k, v in self.list_args.items() + if k not in {"order", "limit", "before", "after"} + } index: int = 0 - while True: if index >= len(self.data): if after is not None: From 79a5de376d4645e28a3005356a15fe1a61eb35b1 Mon Sep 17 00:00:00 2001 From: Matt Dzwonczyk <9063128+mattgd@users.noreply.github.com> Date: Wed, 24 Jul 2024 15:57:24 -0500 Subject: [PATCH 04/18] Add async support for Directory Sync (#288) --- tests/conftest.py | 68 +++++- tests/test_async_events.py | 83 ------- tests/test_client.py | 49 +++- tests/test_directory_sync.py | 373 ++++++++++++++++++++++++++---- tests/test_events.py | 92 +++++++- workos/async_client.py | 7 +- workos/client.py | 2 +- workos/directory_sync.py | 290 ++++++++++++++++++++--- workos/events.py | 9 +- workos/resources/list.py | 63 ++++- workos/typing/sync_or_async.py | 5 + workos/utils/_base_http_client.py | 7 +- workos/utils/http_client.py | 5 +- 13 files changed, 859 insertions(+), 194 deletions(-) delete mode 100644 tests/test_async_events.py create mode 100644 workos/typing/sync_or_async.py diff --git a/tests/conftest.py b/tests/conftest.py index a02a6e6c..e48f35c9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +from typing import Mapping, Union from unittest.mock import AsyncMock, MagicMock import httpx @@ -6,7 +7,7 @@ from tests.utils.list_resource import list_response_of import workos -from workos.utils.http_client import SyncHTTPClient +from workos.utils.http_client import AsyncHTTPClient, SyncHTTPClient class MockResponse(object): @@ -124,20 +125,69 @@ def mock(*args, **kwargs): @pytest.fixture -def mock_sync_http_client_with_response(): - def inner(http_client: SyncHTTPClient, response_dict: dict, status_code: int): - http_client._client.request = MagicMock( - return_value=httpx.Response(status_code, json=response_dict), +def mock_http_client_with_response(monkeypatch): + def inner( + http_client: Union[SyncHTTPClient, AsyncHTTPClient], + response_dict: dict, + status_code: int = 200, + headers: Mapping[str, str] = None, + ): + mock_class = ( + AsyncMock if isinstance(http_client, AsyncHTTPClient) else MagicMock ) + mock = mock_class( + return_value=httpx.Response( + status_code=status_code, headers=headers, json=response_dict + ), + ) + monkeypatch.setattr(http_client._client, "request", mock) return inner @pytest.fixture -def mock_async_http_client_with_response(): - def inner(http_client: SyncHTTPClient, response_dict: dict, status_code: int): - http_client._client.request = AsyncMock( - return_value=httpx.Response(status_code, json=response_dict), +def mock_pagination_request_for_http_client(monkeypatch): + # Mocking pagination correctly requires us to index into a list of data + # and correctly set the before and after metadata in the response. + def inner( + http_client: Union[SyncHTTPClient, AsyncHTTPClient], + data_list: list, + status_code: int = 200, + headers: Mapping[str, str] = None, + ): + # For convenient index lookup, store the list of object IDs. + data_ids = list(map(lambda x: x["id"], data_list)) + + def mock_function(*args, **kwargs): + params = kwargs.get("params") or {} + request_after = params.get("after", None) + limit = params.get("limit", 10) + + if request_after is None: + # First page + start = 0 + else: + # A subsequent page, return the first item _after_ the index we locate + start = data_ids.index(request_after) + 1 + data = data_list[start : start + limit] + if len(data) < limit or len(data) == 0: + # No more data, set after to None + after = None + else: + # Set after to the last item in this page of results + after = data[-1]["id"] + + return httpx.Response( + status_code=status_code, + headers=headers, + json=list_response_of(data=data, before=request_after, after=after), + ) + + mock_class = ( + AsyncMock if isinstance(http_client, AsyncHTTPClient) else MagicMock ) + mock = mock_class(side_effect=mock_function) + + monkeypatch.setattr(http_client._client, "request", mock) return inner diff --git a/tests/test_async_events.py b/tests/test_async_events.py deleted file mode 100644 index 19f99d98..00000000 --- a/tests/test_async_events.py +++ /dev/null @@ -1,83 +0,0 @@ -import pytest - -from tests.utils.fixtures.mock_event import MockEvent -from workos.events import AsyncEvents -from workos.utils.http_client import AsyncHTTPClient - - -class TestAsyncEvents(object): - @pytest.fixture(autouse=True) - def setup( - self, - set_api_key, - set_client_id, - ): - self.http_client = AsyncHTTPClient( - base_url="https://api.workos.test", version="test" - ) - self.events = AsyncEvents(http_client=self.http_client) - - @pytest.fixture - def mock_events(self): - events = [MockEvent(id=str(i)).to_dict() for i in range(10)] - - return { - "data": events, - "metadata": { - "params": { - "events": ["dsync.user.created"], - "limit": 10, - "organization_id": None, - "after": None, - "range_start": None, - "range_end": None, - }, - "method": AsyncEvents.list_events, - }, - } - - @pytest.mark.asyncio - async def test_list_events(self, mock_events, mock_async_http_client_with_response): - mock_async_http_client_with_response( - http_client=self.http_client, - status_code=200, - response_dict={"data": mock_events["data"]}, - ) - - events = await self.events.list_events(events=["dsync.user.created"]) - - assert events == mock_events - - @pytest.mark.asyncio - async def test_list_events_returns_metadata( - self, mock_events, mock_async_http_client_with_response - ): - mock_async_http_client_with_response( - http_client=self.http_client, - status_code=200, - response_dict={"data": mock_events["data"]}, - ) - - events = await self.events.list_events( - events=["dsync.user.created"], - ) - - assert events["metadata"]["params"]["events"] == ["dsync.user.created"] - - @pytest.mark.asyncio - async def test_list_events_with_organization_id_returns_metadata( - self, mock_events, mock_async_http_client_with_response - ): - mock_async_http_client_with_response( - http_client=self.http_client, - status_code=200, - response_dict={"data": mock_events["data"]}, - ) - - events = await self.events.list_events( - events=["dsync.user.created"], - organization_id="org_1234", - ) - - assert events["metadata"]["params"]["organization_id"] == "org_1234" - assert events["metadata"]["params"]["events"] == ["dsync.user.created"] diff --git a/tests/test_client.py b/tests/test_client.py index c9b5c64b..d7eabea9 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,6 +1,6 @@ import pytest -from workos import client +from workos import async_client, client from workos.exceptions import ConfigurationException @@ -9,6 +9,7 @@ class TestClient(object): def setup(self): client._audit_logs = None client._directory_sync = None + client._events = None client._organizations = None client._passwordless = None client._portal = None @@ -24,6 +25,9 @@ def test_initialize_audit_logs(self, set_api_key): def test_initialize_directory_sync(self, set_api_key): assert bool(client.directory_sync) + def test_initialize_events(self, set_api_key): + assert bool(client.events) + def test_initialize_organizations(self, set_api_key): assert bool(client.organizations) @@ -76,6 +80,14 @@ def test_initialize_directory_sync_missing_api_key(self): assert "api_key" in message + def test_initialize_events_missing_api_key(self): + with pytest.raises(ConfigurationException) as ex: + client.events + + message = str(ex) + + assert "api_key" in message + def test_initialize_organizations_missing_api_key(self): with pytest.raises(ConfigurationException) as ex: client.organizations @@ -124,3 +136,38 @@ def test_initialize_user_management_missing_api_key_and_client_id(self): assert "api_key" in message assert "client_id" in message + + +class TestAsyncClient(object): + @pytest.fixture(autouse=True) + def setup(self): + async_client._audit_logs = None + async_client._directory_sync = None + async_client._events = None + async_client._organizations = None + async_client._passwordless = None + async_client._portal = None + async_client._sso = None + async_client._user_management = None + + def test_initialize_directory_sync(self, set_api_key): + assert bool(async_client.directory_sync) + + def test_initialize_directory_sync_missing_api_key(self): + with pytest.raises(ConfigurationException) as ex: + async_client.directory_sync + + message = str(ex) + + assert "api_key" in message + + def test_initialize_events(self, set_api_key): + assert bool(async_client.events) + + def test_initialize_events_missing_api_key(self): + with pytest.raises(ConfigurationException) as ex: + async_client.events + + message = str(ex) + + assert "api_key" in message diff --git a/tests/test_directory_sync.py b/tests/test_directory_sync.py index 3afd73e0..5ed9e645 100644 --- a/tests/test_directory_sync.py +++ b/tests/test_directory_sync.py @@ -1,17 +1,14 @@ import pytest from tests.utils.list_resource import list_data_to_dicts, list_response_of -from workos.directory_sync import DirectorySync +from workos.directory_sync import AsyncDirectorySync, DirectorySync +from workos.utils.http_client import AsyncHTTPClient, SyncHTTPClient from tests.utils.fixtures.mock_directory import MockDirectory from tests.utils.fixtures.mock_directory_user import MockDirectoryUser from tests.utils.fixtures.mock_directory_group import MockDirectoryGroup -class TestDirectorySync(object): - @pytest.fixture(autouse=True) - def setup(self, set_api_key, set_client_id): - self.directory_sync = DirectorySync() - +class DirectorySyncFixtures: @pytest.fixture def mock_users(self): user_list = [MockDirectoryUser(id=str(i)).to_dict() for i in range(100)] @@ -101,43 +98,72 @@ def mock_directory_groups_multiple_data_pages(self): def mock_directory(self): return MockDirectory("directory_id").to_dict() - def test_list_users_with_directory(self, mock_users, mock_request_method): - mock_request_method("get", mock_users, 200) + +class TestDirectorySync(DirectorySyncFixtures): + @pytest.fixture(autouse=True) + def setup(self, set_api_key, set_client_id): + self.http_client = SyncHTTPClient( + base_url="https://api.workos.test", version="test" + ) + self.directory_sync = DirectorySync(http_client=self.http_client) + + def test_list_users_with_directory( + self, mock_users, mock_http_client_with_response + ): + mock_http_client_with_response( + http_client=self.http_client, status_code=200, response_dict=mock_users + ) users = self.directory_sync.list_users(directory="directory_id") assert list_data_to_dicts(users.data) == mock_users["data"] - def test_list_users_with_group(self, mock_users, mock_request_method): - mock_request_method("get", mock_users, 200) + def test_list_users_with_group(self, mock_users, mock_http_client_with_response): + mock_http_client_with_response( + http_client=self.http_client, status_code=200, response_dict=mock_users + ) users = self.directory_sync.list_users(group="directory_grp_id") assert list_data_to_dicts(users.data) == mock_users["data"] - def test_list_groups_with_directory(self, mock_groups, mock_request_method): - mock_request_method("get", mock_groups, 200) + def test_list_groups_with_directory( + self, mock_groups, mock_http_client_with_response + ): + mock_http_client_with_response( + http_client=self.http_client, status_code=200, response_dict=mock_groups + ) groups = self.directory_sync.list_groups(directory="directory_id") assert list_data_to_dicts(groups.data) == mock_groups["data"] - def test_list_groups_with_user(self, mock_groups, mock_request_method): - mock_request_method("get", mock_groups, 200) + def test_list_groups_with_user(self, mock_groups, mock_http_client_with_response): + mock_http_client_with_response( + http_client=self.http_client, status_code=200, response_dict=mock_groups + ) groups = self.directory_sync.list_groups(user="directory_usr_id") assert list_data_to_dicts(groups.data) == mock_groups["data"] - def test_get_user(self, mock_user, mock_request_method): - mock_request_method("get", mock_user, 200) + def test_get_user(self, mock_user, mock_http_client_with_response): + mock_http_client_with_response( + http_client=self.http_client, + status_code=200, + response_dict=mock_user, + ) user = self.directory_sync.get_user(user="directory_usr_id") assert user.dict() == mock_user - def test_get_group(self, mock_group, mock_request_method): - mock_request_method("get", mock_group, 200) + def test_get_group(self, mock_group, mock_http_client_with_response): + mock_http_client_with_response( + http_client=self.http_client, + status_code=200, + response_dict=mock_group, + ) group = self.directory_sync.get_group( group="directory_group_01FHGRYAQ6ERZXXXXXX1E01QFE" @@ -145,25 +171,33 @@ def test_get_group(self, mock_group, mock_request_method): assert group.dict() == mock_group - def test_list_directories(self, mock_directories, mock_request_method): - mock_request_method("get", mock_directories, 200) + def test_list_directories(self, mock_directories, mock_http_client_with_response): + mock_http_client_with_response( + http_client=self.http_client, + status_code=200, + response_dict=mock_directories, + ) directories = self.directory_sync.list_directories() assert list_data_to_dicts(directories.data) == mock_directories["data"] - def test_get_directory(self, mock_directory, mock_request_method): - mock_request_method("get", mock_directory, 200) + def test_get_directory(self, mock_directory, mock_http_client_with_response): + mock_http_client_with_response( + http_client=self.http_client, + status_code=200, + response_dict=mock_directory, + ) directory = self.directory_sync.get_directory(directory="directory_id") assert directory.dict() == mock_directory - def test_delete_directory(self, mock_directories, mock_raw_request_method): - mock_raw_request_method( - "delete", - "Accepted", - 202, + def test_delete_directory(self, mock_http_client_with_response): + mock_http_client_with_response( + http_client=self.http_client, + status_code=202, + response_dict=None, headers={"content-type": "text/plain; charset=utf-8"}, ) @@ -172,9 +206,13 @@ def test_delete_directory(self, mock_directories, mock_raw_request_method): assert response is None def test_primary_email( - self, mock_user, mock_user_primary_email, mock_request_method + self, mock_user, mock_user_primary_email, mock_http_client_with_response ): - mock_request_method("get", mock_user, 200) + mock_http_client_with_response( + http_client=self.http_client, + status_code=200, + response_dict=mock_user, + ) mock_user_instance = self.directory_sync.get_user( "directory_user_01E1JG7J09H96KYP8HM9B0G5SJ" ) @@ -182,8 +220,14 @@ def test_primary_email( assert primary_email assert primary_email.dict() == mock_user_primary_email - def test_primary_email_none(self, mock_user_no_email, mock_request_method): - mock_request_method("get", mock_user_no_email, 200) + def test_primary_email_none( + self, mock_user_no_email, mock_http_client_with_response + ): + mock_http_client_with_response( + http_client=self.http_client, + status_code=200, + response_dict=mock_user_no_email, + ) mock_user_instance = self.directory_sync.get_user( "directory_user_01E1JG7J09H96KYP8HM9B0G5SJ" ) @@ -195,9 +239,13 @@ def test_primary_email_none(self, mock_user_no_email, mock_request_method): def test_list_directories_auto_pagination( self, mock_directories_multiple_data_pages, - mock_pagination_request, + mock_pagination_request_for_http_client, ): - mock_pagination_request("get", mock_directories_multiple_data_pages, 200) + mock_pagination_request_for_http_client( + http_client=self.http_client, + data_list=mock_directories_multiple_data_pages, + status_code=200, + ) directories = self.directory_sync.list_directories() all_directories = [] @@ -213,9 +261,13 @@ def test_list_directories_auto_pagination( def test_directory_users_auto_pagination( self, mock_directory_users_multiple_data_pages, - mock_pagination_request, + mock_pagination_request_for_http_client, ): - mock_pagination_request("get", mock_directory_users_multiple_data_pages, 200) + mock_pagination_request_for_http_client( + http_client=self.http_client, + data_list=mock_directory_users_multiple_data_pages, + status_code=200, + ) users = self.directory_sync.list_users() all_users = [] @@ -231,9 +283,13 @@ def test_directory_users_auto_pagination( def test_directory_user_groups_auto_pagination( self, mock_directory_groups_multiple_data_pages, - mock_pagination_request, + mock_pagination_request_for_http_client, ): - mock_pagination_request("get", mock_directory_groups_multiple_data_pages, 200) + mock_pagination_request_for_http_client( + http_client=self.http_client, + data_list=mock_directory_groups_multiple_data_pages, + status_code=200, + ) groups = self.directory_sync.list_groups() all_groups = [] @@ -249,10 +305,14 @@ def test_directory_user_groups_auto_pagination( def test_auto_pagination_honors_limit( self, mock_directories_multiple_data_pages, - mock_pagination_request, + mock_pagination_request_for_http_client, ): # TODO: This does not actually test anything about the limit. - mock_pagination_request("get", mock_directories_multiple_data_pages, 200) + mock_pagination_request_for_http_client( + http_client=self.http_client, + data_list=mock_directories_multiple_data_pages, + status_code=200, + ) directories = self.directory_sync.list_directories() all_directories = [] @@ -264,3 +324,238 @@ def test_auto_pagination_honors_limit( assert ( list_data_to_dicts(all_directories) ) == mock_directories_multiple_data_pages + + +@pytest.mark.asyncio +class TestAsyncDirectorySync(DirectorySyncFixtures): + @pytest.fixture(autouse=True) + def setup(self, set_api_key, set_client_id): + self.http_client = AsyncHTTPClient( + base_url="https://api.workos.test", + version="test", + ) + self.directory_sync = AsyncDirectorySync(http_client=self.http_client) + + async def test_list_users_with_directory( + self, mock_users, mock_http_client_with_response + ): + mock_http_client_with_response( + http_client=self.http_client, status_code=200, response_dict=mock_users + ) + + users = await self.directory_sync.list_users(directory="directory_id") + + assert list_data_to_dicts(users.data) == mock_users["data"] + + async def test_list_users_with_group( + self, mock_users, mock_http_client_with_response + ): + mock_http_client_with_response( + http_client=self.http_client, status_code=200, response_dict=mock_users + ) + + users = await self.directory_sync.list_users(group="directory_grp_id") + + assert list_data_to_dicts(users.data) == mock_users["data"] + + async def test_list_groups_with_directory( + self, mock_groups, mock_http_client_with_response + ): + mock_http_client_with_response( + http_client=self.http_client, status_code=200, response_dict=mock_groups + ) + + groups = await self.directory_sync.list_groups(directory="directory_id") + + assert list_data_to_dicts(groups.data) == mock_groups["data"] + + async def test_list_groups_with_user( + self, mock_groups, mock_http_client_with_response + ): + mock_http_client_with_response( + http_client=self.http_client, status_code=200, response_dict=mock_groups + ) + + groups = await self.directory_sync.list_groups(user="directory_usr_id") + + assert list_data_to_dicts(groups.data) == mock_groups["data"] + + async def test_get_user(self, mock_user, mock_http_client_with_response): + mock_http_client_with_response( + http_client=self.http_client, + status_code=200, + response_dict=mock_user, + ) + + user = await self.directory_sync.get_user(user="directory_usr_id") + + assert user.dict() == mock_user + + async def test_get_group(self, mock_group, mock_http_client_with_response): + mock_http_client_with_response( + http_client=self.http_client, + status_code=200, + response_dict=mock_group, + ) + + group = await self.directory_sync.get_group( + group="directory_group_01FHGRYAQ6ERZXXXXXX1E01QFE" + ) + + assert group.dict() == mock_group + + async def test_list_directories( + self, mock_directories, mock_http_client_with_response + ): + mock_http_client_with_response( + http_client=self.http_client, + status_code=200, + response_dict=mock_directories, + ) + + directories = await self.directory_sync.list_directories() + + assert list_data_to_dicts(directories.data) == mock_directories["data"] + + async def test_get_directory(self, mock_directory, mock_http_client_with_response): + mock_http_client_with_response( + http_client=self.http_client, + status_code=200, + response_dict=mock_directory, + ) + + directory = await self.directory_sync.get_directory(directory="directory_id") + + assert directory.dict() == mock_directory + + async def test_delete_directory(self, mock_http_client_with_response): + mock_http_client_with_response( + http_client=self.http_client, + status_code=202, + response_dict=None, + headers={"content-type": "text/plain; charset=utf-8"}, + ) + + response = await self.directory_sync.delete_directory(directory="directory_id") + + assert response is None + + async def test_primary_email( + self, mock_user, mock_user_primary_email, mock_http_client_with_response + ): + mock_http_client_with_response( + http_client=self.http_client, + status_code=200, + response_dict=mock_user, + ) + mock_user_instance = await self.directory_sync.get_user( + "directory_user_01E1JG7J09H96KYP8HM9B0G5SJ" + ) + primary_email = mock_user_instance.primary_email() + assert primary_email + assert primary_email.dict() == mock_user_primary_email + + async def test_primary_email_none( + self, mock_user_no_email, mock_http_client_with_response + ): + mock_http_client_with_response( + http_client=self.http_client, + status_code=200, + response_dict=mock_user_no_email, + ) + mock_user_instance = await self.directory_sync.get_user( + "directory_user_01E1JG7J09H96KYP8HM9B0G5SJ" + ) + + me = mock_user_instance.primary_email() + + assert me == None + + async def test_list_directories_auto_pagination( + self, + mock_directories_multiple_data_pages, + mock_pagination_request_for_http_client, + ): + mock_pagination_request_for_http_client( + http_client=self.http_client, + data_list=mock_directories_multiple_data_pages, + status_code=200, + ) + + directories = await self.directory_sync.list_directories() + all_directories = [] + + async for directory in directories.auto_paging_iter(): + all_directories.append(directory) + + assert len(list(all_directories)) == len(mock_directories_multiple_data_pages) + assert ( + list_data_to_dicts(all_directories) + ) == mock_directories_multiple_data_pages + + async def test_directory_users_auto_pagination( + self, + mock_directory_users_multiple_data_pages, + mock_pagination_request_for_http_client, + ): + mock_pagination_request_for_http_client( + http_client=self.http_client, + data_list=mock_directory_users_multiple_data_pages, + status_code=200, + ) + + users = await self.directory_sync.list_users() + all_users = [] + + async for user in users.auto_paging_iter(): + all_users.append(user) + + assert len(list(all_users)) == len(mock_directory_users_multiple_data_pages) + assert ( + list_data_to_dicts(all_users) + ) == mock_directory_users_multiple_data_pages + + async def test_directory_user_groups_auto_pagination( + self, + mock_directory_groups_multiple_data_pages, + mock_pagination_request_for_http_client, + ): + mock_pagination_request_for_http_client( + http_client=self.http_client, + data_list=mock_directory_groups_multiple_data_pages, + status_code=200, + ) + + groups = await self.directory_sync.list_groups() + all_groups = [] + + async for group in groups.auto_paging_iter(): + all_groups.append(group) + + assert len(list(all_groups)) == len(mock_directory_groups_multiple_data_pages) + assert ( + list_data_to_dicts(all_groups) + ) == mock_directory_groups_multiple_data_pages + + async def test_auto_pagination_honors_limit( + self, + mock_directories_multiple_data_pages, + mock_pagination_request_for_http_client, + ): + # TODO: This does not actually test anything about the limit. + mock_pagination_request_for_http_client( + http_client=self.http_client, + data_list=mock_directories_multiple_data_pages, + status_code=200, + ) + + directories = await self.directory_sync.list_directories() + all_directories = [] + + async for directory in directories.auto_paging_iter(): + all_directories.append(directory) + + assert len(list(all_directories)) == len(mock_directories_multiple_data_pages) + assert ( + list_data_to_dicts(all_directories) + ) == mock_directories_multiple_data_pages diff --git a/tests/test_events.py b/tests/test_events.py index 56ab3b57..ebaca488 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -1,8 +1,8 @@ import pytest from tests.utils.fixtures.mock_event import MockEvent -from workos.events import Events -from workos.utils.http_client import SyncHTTPClient +from workos.events import AsyncEvents, Events +from workos.utils.http_client import AsyncHTTPClient, SyncHTTPClient class TestEvents(object): @@ -36,8 +36,8 @@ def mock_events(self): }, } - def test_list_events(self, mock_events, mock_sync_http_client_with_response): - mock_sync_http_client_with_response( + def test_list_events(self, mock_events, mock_http_client_with_response): + mock_http_client_with_response( http_client=self.http_client, status_code=200, response_dict={"data": mock_events["data"]}, @@ -48,9 +48,9 @@ def test_list_events(self, mock_events, mock_sync_http_client_with_response): assert events == mock_events def test_list_events_returns_metadata( - self, mock_events, mock_sync_http_client_with_response + self, mock_events, mock_http_client_with_response ): - mock_sync_http_client_with_response( + mock_http_client_with_response( http_client=self.http_client, status_code=200, response_dict={"data": mock_events["data"]}, @@ -63,9 +63,9 @@ def test_list_events_returns_metadata( assert events["metadata"]["params"]["events"] == ["dsync.user.created"] def test_list_events_with_organization_id_returns_metadata( - self, mock_events, mock_sync_http_client_with_response + self, mock_events, mock_http_client_with_response ): - mock_sync_http_client_with_response( + mock_http_client_with_response( http_client=self.http_client, status_code=200, response_dict={"data": mock_events["data"]}, @@ -78,3 +78,79 @@ def test_list_events_with_organization_id_returns_metadata( assert events["metadata"]["params"]["organization_id"] == "org_1234" assert events["metadata"]["params"]["events"] == ["dsync.user.created"] + + +@pytest.mark.asyncio +class TestAsyncEvents(object): + @pytest.fixture(autouse=True) + def setup( + self, + set_api_key, + set_client_id, + ): + self.http_client = AsyncHTTPClient( + base_url="https://api.workos.test", version="test" + ) + self.events = AsyncEvents(http_client=self.http_client) + + @pytest.fixture + def mock_events(self): + events = [MockEvent(id=str(i)).to_dict() for i in range(10)] + + return { + "data": events, + "metadata": { + "params": { + "events": ["dsync.user.created"], + "limit": 10, + "organization_id": None, + "after": None, + "range_start": None, + "range_end": None, + }, + "method": AsyncEvents.list_events, + }, + } + + async def test_list_events(self, mock_events, mock_http_client_with_response): + mock_http_client_with_response( + http_client=self.http_client, + status_code=200, + response_dict={"data": mock_events["data"]}, + ) + + events = await self.events.list_events(events=["dsync.user.created"]) + + assert events == mock_events + + async def test_list_events_returns_metadata( + self, mock_events, mock_http_client_with_response + ): + mock_http_client_with_response( + http_client=self.http_client, + status_code=200, + response_dict={"data": mock_events["data"]}, + ) + + events = await self.events.list_events( + events=["dsync.user.created"], + ) + + assert events["metadata"]["params"]["events"] == ["dsync.user.created"] + + async def test_list_events_with_organization_id_returns_metadata( + self, mock_events, mock_http_client_with_response + ): + mock_http_client_with_response( + http_client=self.http_client, + status_code=200, + response_dict={"data": mock_events["data"]}, + ) + + events = await self.events.list_events( + events=["dsync.user.created"], + organization_id="org_1234", + ) + + assert events["metadata"]["params"]["organization_id"] == "org_1234" + assert events["metadata"]["params"]["events"] == ["dsync.user.created"] diff --git a/workos/async_client.py b/workos/async_client.py index 513ad711..c238c387 100644 --- a/workos/async_client.py +++ b/workos/async_client.py @@ -1,4 +1,5 @@ from workos._base_client import BaseClient +from workos.directory_sync import AsyncDirectorySync from workos.events import AsyncEvents from workos.utils.http_client import AsyncHTTPClient @@ -25,9 +26,9 @@ def audit_logs(self): @property def directory_sync(self): - raise NotImplementedError( - "Directory Sync APIs are not yet supported in the async client." - ) + if not getattr(self, "_directory_sync", None): + self._directory_sync = AsyncDirectorySync(self._http_client) + return self._directory_sync @property def events(self): diff --git a/workos/client.py b/workos/client.py index a2ae7748..82803ed7 100644 --- a/workos/client.py +++ b/workos/client.py @@ -37,7 +37,7 @@ def audit_logs(self): @property def directory_sync(self): if not getattr(self, "_directory_sync", None): - self._directory_sync = DirectorySync() + self._directory_sync = DirectorySync(self._http_client) return self._directory_sync @property diff --git a/workos/directory_sync.py b/workos/directory_sync.py index 5fb72ca8..15c497b7 100644 --- a/workos/directory_sync.py +++ b/workos/directory_sync.py @@ -1,11 +1,9 @@ from typing import Optional, Protocol import workos +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 import ( - RequestHelper, - REQUEST_METHOD_DELETE, - REQUEST_METHOD_GET, -) +from workos.utils.request import REQUEST_METHOD_DELETE, REQUEST_METHOD_GET from workos.utils.validation import DIRECTORY_SYNC_MODULE, validate_settings from workos.resources.directory_sync import ( @@ -13,7 +11,13 @@ Directory, DirectoryUser, ) -from workos.resources.list import ListArgs, ListPage, WorkOsListResource +from workos.resources.list import ( + ListArgs, + ListPage, + AsyncWorkOsListResource, + SyncOrAsyncListResource, + WorkOsListResource, +) RESPONSE_LIMIT = 10 @@ -47,7 +51,7 @@ def list_users( before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", - ) -> WorkOsListResource[DirectoryUser, DirectoryUserListFilters]: + ) -> SyncOrAsyncListResource: ... def list_groups( @@ -58,16 +62,16 @@ def list_groups( before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", - ) -> WorkOsListResource[DirectoryGroup, DirectoryGroupListFilters]: + ) -> SyncOrAsyncListResource: ... - def get_user(self, user: str) -> DirectoryUser: + def get_user(self, user: str) -> SyncOrAsync[DirectoryUser]: ... - def get_group(self, group: str) -> DirectoryGroup: + def get_group(self, group: str) -> SyncOrAsync[DirectoryGroup]: ... - def get_directory(self, directory: str) -> Directory: + def get_directory(self, directory: str) -> SyncOrAsync[Directory]: ... def list_directories( @@ -79,31 +83,27 @@ def list_directories( after: Optional[str] = None, organization: Optional[str] = None, order: PaginationOrder = "desc", - ) -> WorkOsListResource[Directory, DirectoryListFilters]: + ) -> SyncOrAsyncListResource: ... - def delete_directory(self, directory: str) -> None: + def delete_directory(self, directory: str) -> SyncOrAsync[None]: ... class DirectorySync(DirectorySyncModule): """Offers methods through the WorkOS Directory Sync service.""" - @validate_settings(DIRECTORY_SYNC_MODULE) - def __init__(self): - pass + _http_client: SyncHTTPClient - @property - def request_helper(self): - if not getattr(self, "_request_helper", None): - self._request_helper = RequestHelper() - return self._request_helper + @validate_settings(DIRECTORY_SYNC_MODULE) + def __init__(self, http_client: SyncHTTPClient): + self._http_client = http_client def list_users( self, directory: Optional[str] = None, group: Optional[str] = None, - limit: int = RESPONSE_LIMIT, + limit: Optional[int] = RESPONSE_LIMIT, before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", @@ -125,7 +125,7 @@ def list_users( """ list_params: DirectoryUserListFilters = { - "limit": limit, + "limit": limit if limit is not None else RESPONSE_LIMIT, "before": before, "after": after, "order": order, @@ -136,7 +136,7 @@ def list_users( if directory is not None: list_params["directory"] = directory - response = self.request_helper.request( + response = self._http_client.request( "directory_users", method=REQUEST_METHOD_GET, params=list_params, @@ -185,7 +185,7 @@ def list_groups( if directory is not None: list_params["directory"] = directory - response = self.request_helper.request( + response = self._http_client.request( "directory_groups", method=REQUEST_METHOD_GET, params=list_params, @@ -198,7 +198,7 @@ def list_groups( **ListPage[DirectoryGroup](**response).model_dump(), ) - def get_user(self, user: str): + def get_user(self, user: str) -> DirectoryUser: """Gets details for a single provisioned Directory User. Args: @@ -207,7 +207,7 @@ def get_user(self, user: str): Returns: dict: Directory User response from WorkOS. """ - response = self.request_helper.request( + response = self._http_client.request( "directory_users/{user}".format(user=user), method=REQUEST_METHOD_GET, token=workos.api_key, @@ -215,7 +215,7 @@ def get_user(self, user: str): return DirectoryUser.model_validate(response) - def get_group(self, group: str): + def get_group(self, group: str) -> DirectoryGroup: """Gets details for a single provisioned Directory Group. Args: @@ -224,14 +224,14 @@ def get_group(self, group: str): Returns: dict: Directory Group response from WorkOS. """ - response = self.request_helper.request( + response = self._http_client.request( "directory_groups/{group}".format(group=group), method=REQUEST_METHOD_GET, token=workos.api_key, ) return DirectoryGroup.model_validate(response) - def get_directory(self, directory: str): + def get_directory(self, directory: str) -> Directory: """Gets details for a single Directory Args: @@ -242,7 +242,7 @@ def get_directory(self, directory: str): """ - response = self.request_helper.request( + response = self._http_client.request( "directories/{directory}".format(directory=directory), method=REQUEST_METHOD_GET, token=workos.api_key, @@ -285,7 +285,7 @@ def list_directories( "search": search, } - response = self.request_helper.request( + response = self._http_client.request( "directories", method=REQUEST_METHOD_GET, params=list_params, @@ -297,7 +297,229 @@ def list_directories( **ListPage[Directory](**response).model_dump(), ) - def delete_directory(self, directory: str): + def delete_directory(self, directory: str) -> None: + """Delete one existing Directory. + + Args: + directory (str): The ID of the directory to be deleted. (Required) + + Returns: + None + """ + self._http_client.request( + "directories/{directory}".format(directory=directory), + method=REQUEST_METHOD_DELETE, + token=workos.api_key, + ) + + +class AsyncDirectorySync(DirectorySyncModule): + """Offers methods through the WorkOS Directory Sync service.""" + + _http_client: AsyncHTTPClient + + @validate_settings(DIRECTORY_SYNC_MODULE) + def __init__(self, http_client: AsyncHTTPClient): + self._http_client = http_client + + async def list_users( + self, + directory: Optional[str] = None, + group: Optional[str] = None, + limit: int = RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> AsyncWorkOsListResource[DirectoryUser, DirectoryUserListFilters]: + """Gets a list of provisioned Users for a Directory. + + Note, either 'directory' or 'group' must be provided. + + Args: + directory (str): Directory unique identifier. + group (str): Directory Group unique identifier. + limit (int): Maximum number of records to return. + before (str): Pagination cursor to receive records before a provided Directory ID. + after (str): Pagination cursor to receive records after a provided Directory ID. + order (Order): Sort records in either ascending or descending order by created_at timestamp. + + Returns: + dict: Directory Users response from WorkOS. + """ + + list_params = { + "limit": limit, + "before": before, + "after": after, + "order": order, + } + + if group is not None: + list_params["group"] = group + if directory is not None: + list_params["directory"] = directory + + response = await self._http_client.request( + "directory_users", + method=REQUEST_METHOD_GET, + params=list_params, + token=workos.api_key, + ) + + return AsyncWorkOsListResource( + list_method=self.list_users, + list_args=list_params, + **ListPage[DirectoryUser](**response).model_dump(), + ) + + async def list_groups( + self, + directory: Optional[str] = None, + user: Optional[str] = None, + limit: int = RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> AsyncWorkOsListResource[DirectoryGroup, DirectoryGroupListFilters]: + """Gets a list of provisioned Groups for a Directory . + + Note, either 'directory' or 'user' must be provided. + + Args: + directory (str): Directory unique identifier. + user (str): Directory User unique identifier. + limit (int): Maximum number of records to return. + before (str): Pagination cursor to receive records before a provided Directory ID. + after (str): Pagination cursor to receive records after a provided Directory ID. + order (Order): Sort records in either ascending or descending order by created_at timestamp. + + Returns: + dict: Directory Groups response from WorkOS. + """ + list_params = { + "limit": limit, + "before": before, + "after": after, + "order": order, + } + if user is not None: + list_params["user"] = user + if directory is not None: + list_params["directory"] = directory + + response = await self._http_client.request( + "directory_groups", + method=REQUEST_METHOD_GET, + params=list_params, + token=workos.api_key, + ) + + return AsyncWorkOsListResource( + list_method=self.list_groups, + list_args=list_params, + **ListPage[DirectoryGroup](**response).model_dump(), + ) + + async def get_user(self, user: str) -> DirectoryUser: + """Gets details for a single provisioned Directory User. + + Args: + user (str): Directory User unique identifier. + + Returns: + dict: Directory User response from WorkOS. + """ + response = await self._http_client.request( + "directory_users/{user}".format(user=user), + method=REQUEST_METHOD_GET, + token=workos.api_key, + ) + + return DirectoryUser.model_validate(response) + + async def get_group(self, group: str) -> DirectoryGroup: + """Gets details for a single provisioned Directory Group. + + Args: + group (str): Directory Group unique identifier. + + Returns: + dict: Directory Group response from WorkOS. + """ + response = await self._http_client.request( + "directory_groups/{group}".format(group=group), + method=REQUEST_METHOD_GET, + token=workos.api_key, + ) + return DirectoryGroup.model_validate(response) + + async def get_directory(self, directory: str) -> Directory: + """Gets details for a single Directory + + Args: + directory (str): Directory unique identifier. + + Returns: + dict: Directory response from WorkOS + + """ + + response = await self._http_client.request( + "directories/{directory}".format(directory=directory), + method=REQUEST_METHOD_GET, + token=workos.api_key, + ) + + return Directory.model_validate(response) + + async def list_directories( + self, + domain: Optional[str] = None, + search: Optional[str] = None, + limit: int = RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + organization: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> AsyncWorkOsListResource[Directory, DirectoryListFilters]: + """Gets details for existing Directories. + + Args: + domain (str): Domain of a Directory. (Optional) + organization: ID of an Organization (Optional) + search (str): Searchable text for a Directory. (Optional) + limit (int): Maximum number of records to return. (Optional) + before (str): Pagination cursor to receive records before a provided Directory ID. (Optional) + after (str): Pagination cursor to receive records after a provided Directory ID. (Optional) + order (Order): Sort records in either ascending or descending order by created_at timestamp. + + Returns: + dict: Directories response from WorkOS. + """ + + list_params = { + "domain": domain, + "organization": organization, + "search": search, + "limit": limit, + "before": before, + "after": after, + "order": order, + } + + response = await self._http_client.request( + "directories", + method=REQUEST_METHOD_GET, + params=list_params, + token=workos.api_key, + ) + return AsyncWorkOsListResource( + list_method=self.list_directories, + list_args=list_params, + **ListPage[Directory](**response).model_dump(), + ) + + async def delete_directory(self, directory: str) -> None: """Delete one existing Directory. Args: @@ -306,7 +528,7 @@ def delete_directory(self, directory: str): Returns: None """ - self.request_helper.request( + await self._http_client.request( "directories/{directory}".format(directory=directory), method=REQUEST_METHOD_DELETE, token=workos.api_key, diff --git a/workos/events.py b/workos/events.py index d612b25d..fc047a86 100644 --- a/workos/events.py +++ b/workos/events.py @@ -1,9 +1,8 @@ -from typing import Awaitable, List, Optional, Protocol, Union +from typing import List, Optional, Protocol import workos -from workos.utils.request import ( - REQUEST_METHOD_GET, -) +from workos.typing.sync_or_async import SyncOrAsync +from workos.utils.request import REQUEST_METHOD_GET from workos.utils.http_client import AsyncHTTPClient, SyncHTTPClient from workos.utils.validation import EVENTS_MODULE, validate_settings from workos.resources.list import WorkOSListResource @@ -21,7 +20,7 @@ def list_events( after: Optional[str] = None, range_start: Optional[str] = None, range_end: Optional[str] = None, - ) -> Union[dict, Awaitable[dict]]: + ) -> SyncOrAsync[dict]: ... diff --git a/workos/resources/list.py b/workos/resources/list.py index 99ceda18..145ba4ae 100644 --- a/workos/resources/list.py +++ b/workos/resources/list.py @@ -1,4 +1,7 @@ +import abc from typing import ( + AsyncIterator, + Awaitable, List, Literal, TypeVar, @@ -6,6 +9,7 @@ Callable, Iterator, Optional, + Union, ) from typing_extensions import TypedDict from workos.resources.base import WorkOSBaseResource @@ -137,7 +141,7 @@ class ListArgs(TypedDict): ListAndFilterParams = TypeVar("ListAndFilterParams", bound=ListArgs) -class WorkOsListResource( +class BaseWorkOsListResource( WorkOSModel, Generic[ListableResource, ListAndFilterParams], ): @@ -148,11 +152,7 @@ class WorkOsListResource( list_method: Callable = Field(exclude=True) list_args: ListAndFilterParams = Field(exclude=True) - def auto_paging_iter(self) -> Iterator[ListableResource]: - next_page: WorkOsListResource[ListableResource, ListAndFilterParams] - - after = self.list_metadata.after - + def _parse_params(self): fixed_pagination_params = { "order": self.list_args["order"], "limit": self.list_args["limit"], @@ -163,7 +163,26 @@ def auto_paging_iter(self) -> Iterator[ListableResource]: for k, v in self.list_args.items() if k not in {"order", "limit", "before", "after"} } + + return fixed_pagination_params, filter_params + + @abc.abstractmethod + def auto_paging_iter( + self, + ) -> Union[AsyncIterator[ListableResource], Iterator[ListableResource]]: + ... + + +class WorkOsListResource( + BaseWorkOsListResource, + Generic[ListableResource, ListAndFilterParams], +): + def auto_paging_iter(self) -> Iterator[ListableResource]: + next_page: WorkOsListResource[ListableResource, ListAndFilterParams] + after = self.list_metadata.after + fixed_pagination_params, filter_params = self._parse_params() index: int = 0 + while True: if index >= len(self.data): if after is not None: @@ -178,3 +197,35 @@ def auto_paging_iter(self) -> Iterator[ListableResource]: return yield self.data[index] index += 1 + + +class AsyncWorkOsListResource( + BaseWorkOsListResource, + Generic[ListableResource, ListAndFilterParams], +): + async def auto_paging_iter(self) -> AsyncIterator[ListableResource]: + next_page: WorkOsListResource[ListableResource, ListAndFilterParams] + after = self.list_metadata.after + fixed_pagination_params, filter_params = self._parse_params() + index: int = 0 + + while True: + if index >= len(self.data): + if after is not None: + next_page = await self.list_method( + after=after, **fixed_pagination_params, **filter_params + ) + self.data = next_page.data + after = next_page.list_metadata.after + index = 0 + continue + else: + return + yield self.data[index] + index += 1 + + +SyncOrAsyncListResource = Union[ + Awaitable[AsyncWorkOsListResource], + WorkOsListResource, +] diff --git a/workos/typing/sync_or_async.py b/workos/typing/sync_or_async.py new file mode 100644 index 00000000..d336c76e --- /dev/null +++ b/workos/typing/sync_or_async.py @@ -0,0 +1,5 @@ +from typing import Awaitable, TypeVar, Union + + +T = TypeVar("T") +SyncOrAsync = Union[T, Awaitable[T]] diff --git a/workos/utils/_base_http_client.py b/workos/utils/_base_http_client.py index aab0f7d6..0baeca9d 100644 --- a/workos/utils/_base_http_client.py +++ b/workos/utils/_base_http_client.py @@ -3,6 +3,7 @@ cast, Dict, Generic, + Mapping, Optional, TypeVar, TypedDict, @@ -32,8 +33,8 @@ class PreparedRequest(TypedDict): method: str url: str headers: httpx.Headers - params: NotRequired[Union[Dict, None]] - json: NotRequired[Union[Dict, None]] + params: NotRequired[Union[Mapping, None]] + json: NotRequired[Union[Mapping, None]] timeout: int @@ -104,7 +105,7 @@ def _prepare_request( self, path: str, method: Optional[str] = REQUEST_METHOD_GET, - params: Optional[dict] = None, + params: Optional[Mapping] = None, headers: Optional[dict] = None, token: Optional[str] = None, ) -> PreparedRequest: diff --git a/workos/utils/http_client.py b/workos/utils/http_client.py index d89f324f..838dd49b 100644 --- a/workos/utils/http_client.py +++ b/workos/utils/http_client.py @@ -1,8 +1,9 @@ import asyncio -from typing import Awaitable, Optional +from typing import Dict, Mapping, Optional, Union, TypedDict import httpx +from workos.resources.list import ListArgs from workos.utils._base_http_client import BaseHTTPClient from workos.utils.request import REQUEST_METHOD_GET @@ -68,7 +69,7 @@ def request( self, path: str, method: Optional[str] = REQUEST_METHOD_GET, - params: Optional[dict] = None, + params: Optional[Mapping] = None, headers: Optional[dict] = None, token: Optional[str] = None, ) -> dict: From 1ded44401e7300be4d4339627928975aaa0b448b Mon Sep 17 00:00:00 2001 From: Matt Dzwonczyk <9063128+mattgd@users.noreply.github.com> Date: Thu, 25 Jul 2024 08:24:24 -0500 Subject: [PATCH 05/18] Formatting and dependency upgrades (#290) --- .github/workflows/ci.yml | 6 +- .gitignore | 3 + mypy.ini | 2 +- setup.py | 14 ++-- workos/_base_client.py | 30 +++---- workos/audit_logs.py | 9 +- workos/directory_sync.py | 21 ++--- workos/events.py | 3 +- workos/mfa.py | 18 ++-- workos/organizations.py | 18 ++-- workos/passwordless.py | 6 +- workos/portal.py | 3 +- workos/resources/list.py | 3 +- workos/resources/workos_model.py | 1 - workos/sso.py | 21 ++--- workos/typing/untyped_literal.py | 2 +- workos/user_management.py | 139 ++++++++++++------------------- workos/utils/http_client.py | 3 +- workos/webhooks.py | 12 +-- 19 files changed, 114 insertions(+), 200 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5db9d08..ae1cef23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: push: branches: - - 'main' + - "main" pull_request: {} defaults: @@ -17,13 +17,13 @@ jobs: strategy: fail-fast: false matrix: - python: ['3.8', '3.9', '3.10', '3.11'] + python: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} - cache: 'pip' + cache: "pip" cache-dependency-path: setup.py - name: Install dependencies diff --git a/.gitignore b/.gitignore index ddc4f82d..0f3fc09c 100644 --- a/.gitignore +++ b/.gitignore @@ -131,3 +131,6 @@ dmypy.json # VSCode .vscode/ + +# macOS +.DS_Store \ No newline at end of file diff --git a/mypy.ini b/mypy.ini index 5a47b116..245e450f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,2 +1,2 @@ [mypy] -files=./workos/**/*/organizations.py,./workos/**/*/directory_sync.py \ No newline at end of file +files=./workos/**/*.py \ No newline at end of file diff --git a/setup.py b/setup.py index 5face519..d5011429 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,4 @@ import os -import sys from setuptools import setup, find_packages base_dir = os.path.dirname(__file__) @@ -36,15 +35,14 @@ extras_require={ "dev": [ "flake8", - "pytest==8.1.1", + "pytest==8.3.2", "pytest-asyncio==0.23.8", - "pytest-cov==2.8.1", - "six==1.13.0", - "black==22.3.0", - "twine==4.0.2", + "pytest-cov==5.0.0", + "six==1.16.0", + "black==24.4.2", + "twine==5.1.1", "requests==2.30.0", - "urllib3==2.0.2", - "mypy==1.10.1", + "mypy==1.11.0", "httpx>=0.27.0", ], ":python_version<'3.4'": ["enum34"], diff --git a/workos/_base_client.py b/workos/_base_client.py index 7d2b1757..7c4876af 100644 --- a/workos/_base_client.py +++ b/workos/_base_client.py @@ -19,41 +19,31 @@ class BaseClient(Protocol): _http_client: BaseHTTPClient @property - def audit_logs(self) -> AuditLogsModule: - ... + def audit_logs(self) -> AuditLogsModule: ... @property - def directory_sync(self) -> DirectorySyncModule: - ... + def directory_sync(self) -> DirectorySyncModule: ... @property - def events(self) -> EventsModule: - ... + def events(self) -> EventsModule: ... @property - def mfa(self) -> MFAModule: - ... + def mfa(self) -> MFAModule: ... @property - def organizations(self) -> OrganizationsModule: - ... + def organizations(self) -> OrganizationsModule: ... @property - def passwordless(self) -> PasswordlessModule: - ... + def passwordless(self) -> PasswordlessModule: ... @property - def portal(self) -> PortalModule: - ... + def portal(self) -> PortalModule: ... @property - def sso(self) -> SSOModule: - ... + def sso(self) -> SSOModule: ... @property - def user_management(self) -> UserManagementModule: - ... + def user_management(self) -> UserManagementModule: ... @property - def webhooks(self) -> WebhooksModule: - ... + def webhooks(self) -> WebhooksModule: ... diff --git a/workos/audit_logs.py b/workos/audit_logs.py index 92ef9473..9af0267d 100644 --- a/workos/audit_logs.py +++ b/workos/audit_logs.py @@ -13,8 +13,7 @@ class AuditLogsModule(Protocol): def create_event( self, organization: str, event: dict, idempotency_key: Optional[str] = None - ) -> None: - ... + ) -> None: ... def create_export( self, @@ -26,11 +25,9 @@ def create_export( targets=None, actor_names=None, actor_ids=None, - ) -> WorkOSAuditLogExport: - ... + ) -> WorkOSAuditLogExport: ... - def get_export(self, export_id) -> WorkOSAuditLogExport: - ... + def get_export(self, export_id) -> WorkOSAuditLogExport: ... class AuditLogs(AuditLogsModule): diff --git a/workos/directory_sync.py b/workos/directory_sync.py index 15c497b7..ef8902c7 100644 --- a/workos/directory_sync.py +++ b/workos/directory_sync.py @@ -51,8 +51,7 @@ def list_users( before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", - ) -> SyncOrAsyncListResource: - ... + ) -> SyncOrAsyncListResource: ... def list_groups( self, @@ -62,17 +61,13 @@ def list_groups( before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", - ) -> SyncOrAsyncListResource: - ... + ) -> SyncOrAsyncListResource: ... - def get_user(self, user: str) -> SyncOrAsync[DirectoryUser]: - ... + def get_user(self, user: str) -> SyncOrAsync[DirectoryUser]: ... - def get_group(self, group: str) -> SyncOrAsync[DirectoryGroup]: - ... + def get_group(self, group: str) -> SyncOrAsync[DirectoryGroup]: ... - def get_directory(self, directory: str) -> SyncOrAsync[Directory]: - ... + def get_directory(self, directory: str) -> SyncOrAsync[Directory]: ... def list_directories( self, @@ -83,11 +78,9 @@ def list_directories( after: Optional[str] = None, organization: Optional[str] = None, order: PaginationOrder = "desc", - ) -> SyncOrAsyncListResource: - ... + ) -> SyncOrAsyncListResource: ... - def delete_directory(self, directory: str) -> SyncOrAsync[None]: - ... + def delete_directory(self, directory: str) -> SyncOrAsync[None]: ... class DirectorySync(DirectorySyncModule): diff --git a/workos/events.py b/workos/events.py index fc047a86..09346b55 100644 --- a/workos/events.py +++ b/workos/events.py @@ -20,8 +20,7 @@ def list_events( after: Optional[str] = None, range_start: Optional[str] = None, range_end: Optional[str] = None, - ) -> SyncOrAsync[dict]: - ... + ) -> SyncOrAsync[dict]: ... class Events(EventsModule, WorkOSListResource): diff --git a/workos/mfa.py b/workos/mfa.py index 18e5c763..106eb446 100644 --- a/workos/mfa.py +++ b/workos/mfa.py @@ -24,25 +24,19 @@ def enroll_factor( totp_issuer=None, totp_user=None, phone_number=None, - ) -> dict: - ... + ) -> dict: ... - def get_factor(self, authentication_factor_id=None) -> dict: - ... + def get_factor(self, authentication_factor_id=None) -> dict: ... - def delete_factor(self, authentication_factor_id=None) -> None: - ... + def delete_factor(self, authentication_factor_id=None) -> None: ... def challenge_factor( self, authentication_factor_id=None, sms_template=None - ) -> dict: - ... + ) -> dict: ... - def verify_factor(self, authentication_challenge_id=None, code=None) -> dict: - ... + def verify_factor(self, authentication_challenge_id=None, code=None) -> dict: ... - def verify_challenge(self, authentication_challenge_id=None, code=None) -> dict: - ... + def verify_challenge(self, authentication_challenge_id=None, code=None) -> dict: ... class Mfa(MFAModule): diff --git a/workos/organizations.py b/workos/organizations.py index 92287a30..34d4d0dc 100644 --- a/workos/organizations.py +++ b/workos/organizations.py @@ -31,33 +31,27 @@ def list_organizations( before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", - ) -> WorkOsListResource[Organization, OrganizationListFilters]: - ... + ) -> WorkOsListResource[Organization, OrganizationListFilters]: ... - def get_organization(self, organization: str) -> Organization: - ... + def get_organization(self, organization: str) -> Organization: ... - def get_organization_by_lookup_key(self, lookup_key: str) -> Organization: - ... + def get_organization_by_lookup_key(self, lookup_key: str) -> Organization: ... def create_organization( self, name: str, domain_data: Optional[List[DomainDataInput]] = None, idempotency_key: Optional[str] = None, - ) -> Organization: - ... + ) -> Organization: ... def update_organization( self, organization: str, name: str, domain_data: Optional[List[DomainDataInput]] = None, - ) -> Organization: - ... + ) -> Organization: ... - def delete_organization(self, organization: str) -> None: - ... + def delete_organization(self, organization: str) -> None: ... class Organizations(OrganizationsModule): diff --git a/workos/passwordless.py b/workos/passwordless.py index 5a4c186a..26079b42 100644 --- a/workos/passwordless.py +++ b/workos/passwordless.py @@ -7,11 +7,9 @@ class PasswordlessModule(Protocol): - def create_session(self, session_options: dict) -> dict: - ... + def create_session(self, session_options: dict) -> dict: ... - def send_session(self, session_id: str) -> Literal[True]: - ... + def send_session(self, session_id: str) -> Literal[True]: ... class Passwordless(PasswordlessModule): diff --git a/workos/portal.py b/workos/portal.py index a7cac88c..0d7ab798 100644 --- a/workos/portal.py +++ b/workos/portal.py @@ -15,8 +15,7 @@ def generate_link( organization: str, return_url: Optional[str] = None, success_url: Optional[str] = None, - ) -> dict: - ... + ) -> dict: ... class Portal(PortalModule): diff --git a/workos/resources/list.py b/workos/resources/list.py index 145ba4ae..1bdd5fed 100644 --- a/workos/resources/list.py +++ b/workos/resources/list.py @@ -169,8 +169,7 @@ def _parse_params(self): @abc.abstractmethod def auto_paging_iter( self, - ) -> Union[AsyncIterator[ListableResource], Iterator[ListableResource]]: - ... + ) -> Union[AsyncIterator[ListableResource], Iterator[ListableResource]]: ... class WorkOsListResource( diff --git a/workos/resources/workos_model.py b/workos/resources/workos_model.py index 10c182e1..8247a997 100644 --- a/workos/resources/workos_model.py +++ b/workos/resources/workos_model.py @@ -1,4 +1,3 @@ -from typing import Any, Dict from typing_extensions import override from pydantic import BaseModel diff --git a/workos/sso.py b/workos/sso.py index c089a607..08b2d9f4 100644 --- a/workos/sso.py +++ b/workos/sso.py @@ -41,17 +41,13 @@ def get_authorization_url( provider=None, connection=None, organization=None, - ) -> str: - ... + ) -> str: ... - def get_profile(self, accessToken: str) -> WorkOSProfile: - ... + def get_profile(self, accessToken: str) -> WorkOSProfile: ... - def get_profile_and_token(self, code: str) -> WorkOSProfileAndToken: - ... + def get_profile_and_token(self, code: str) -> WorkOSProfileAndToken: ... - def get_connection(self, connection: str) -> dict: - ... + def get_connection(self, connection: str) -> dict: ... def list_connections( self, @@ -62,8 +58,7 @@ def list_connections( before=None, after=None, order=None, - ) -> dict: - ... + ) -> dict: ... def list_connections_v2( self, @@ -74,11 +69,9 @@ def list_connections_v2( before=None, after=None, order=None, - ) -> dict: - ... + ) -> dict: ... - def delete_connection(self, connection: str) -> None: - ... + def delete_connection(self, connection: str) -> None: ... class SSO(SSOModule, WorkOSListResource): diff --git a/workos/typing/untyped_literal.py b/workos/typing/untyped_literal.py index 2092b39e..20cd095d 100644 --- a/workos/typing/untyped_literal.py +++ b/workos/typing/untyped_literal.py @@ -1,6 +1,6 @@ from typing import Any from pydantic_core import CoreSchema, core_schema -from pydantic import GetCoreSchemaHandler, TypeAdapter, ValidationError +from pydantic import GetCoreSchemaHandler class UntypedLiteral(str): diff --git a/workos/user_management.py b/workos/user_management.py index 8124816f..8a664697 100644 --- a/workos/user_management.py +++ b/workos/user_management.py @@ -60,8 +60,7 @@ class UserManagementModule(Protocol): - def get_user(self, user_id: str) -> dict: - ... + def get_user(self, user_id: str) -> dict: ... def list_users( self, @@ -71,30 +70,23 @@ def list_users( before=None, after=None, order=None, - ) -> dict: - ... + ) -> dict: ... - def create_user(self, user: dict) -> dict: - ... + def create_user(self, user: dict) -> dict: ... - def update_user(self, user_id: str, payload: dict) -> dict: - ... + def update_user(self, user_id: str, payload: dict) -> dict: ... - def delete_user(self, user_id: str) -> None: - ... + def delete_user(self, user_id: str) -> None: ... def create_organization_membership( self, user_id: str, organization_id: str, role_slug=None - ) -> dict: - ... + ) -> dict: ... def update_organization_membership( self, organization_membership_id: str, role_slug=None - ) -> dict: - ... + ) -> dict: ... - def get_organization_membership(self, organization_membership_id: str) -> dict: - ... + def get_organization_membership(self, organization_membership_id: str) -> dict: ... def list_organization_memberships( self, @@ -105,21 +97,19 @@ def list_organization_memberships( before=None, after=None, order=None, - ) -> dict: - ... + ) -> dict: ... - def delete_organization_membership(self, organization_membership_id: str) -> None: - ... + def delete_organization_membership( + self, organization_membership_id: str + ) -> None: ... def deactivate_organization_membership( self, organization_membership_id: str - ) -> dict: - ... + ) -> dict: ... def reactivate_organization_membership( self, organization_membership_id: str - ) -> dict: - ... + ) -> dict: ... def get_authorization_url( self, @@ -131,18 +121,15 @@ def get_authorization_url( login_hint=None, state=None, code_challenge=None, - ) -> str: - ... + ) -> str: ... def authenticate_with_password( self, email: str, password: str, ip_address=None, user_agent=None - ) -> dict: - ... + ) -> dict: ... def authenticate_with_code( self, code: str, code_verifier=None, ip_address=None, user_agent=None - ) -> dict: - ... + ) -> dict: ... def authenticate_with_magic_auth( self, @@ -151,8 +138,7 @@ def authenticate_with_magic_auth( link_authorization_code=None, ip_address=None, user_agent=None, - ) -> dict: - ... + ) -> dict: ... def authenticate_with_email_verification( self, @@ -160,8 +146,7 @@ def authenticate_with_email_verification( pending_authentication_token: str, ip_address=None, user_agent=None, - ) -> dict: - ... + ) -> dict: ... def authenticate_with_totp( self, @@ -170,8 +155,7 @@ def authenticate_with_totp( pending_authentication_token: str, ip_address=None, user_agent=None, - ) -> dict: - ... + ) -> dict: ... def authenticate_with_organization_selection( self, @@ -179,54 +163,40 @@ def authenticate_with_organization_selection( pending_authentication_token, ip_address=None, user_agent=None, - ) -> dict: - ... + ) -> dict: ... def authenticate_with_refresh_token( self, refresh_token, ip_address=None, user_agent=None, - ) -> dict: - ... + ) -> dict: ... # TODO: Methods that don't method network requests can just be defined in the base class - def get_jwks_url(self) -> str: - ... + def get_jwks_url(self) -> str: ... # TODO: Methods that don't method network requests can just be defined in the base class - def get_logout_url(self, session_id) -> str: - ... + def get_logout_url(self, session_id) -> str: ... - def get_password_reset(self, password_reset_id) -> dict: - ... + def get_password_reset(self, password_reset_id) -> dict: ... - def create_password_reset(self, email) -> dict: - ... + def create_password_reset(self, email) -> dict: ... - def send_password_reset_email(self, email, password_reset_url) -> None: - ... + def send_password_reset_email(self, email, password_reset_url) -> None: ... - def reset_password(self, token, new_password) -> dict: - ... + def reset_password(self, token, new_password) -> dict: ... - def get_email_verification(self, email_verification_id) -> dict: - ... + def get_email_verification(self, email_verification_id) -> dict: ... - def send_verification_email(self, user_id) -> dict: - ... + def send_verification_email(self, user_id) -> dict: ... - def verify_email(self, user_id, code) -> dict: - ... + def verify_email(self, user_id, code) -> dict: ... - def get_magic_auth(self, magic_auth_id) -> dict: - ... + def get_magic_auth(self, magic_auth_id) -> dict: ... - def create_magic_auth(self, email, invitation_token=None) -> dict: - ... + def create_magic_auth(self, email, invitation_token=None) -> dict: ... - def send_magic_auth_code(self, email) -> None: - ... + def send_magic_auth_code(self, email) -> None: ... def enroll_auth_factor( self, @@ -235,17 +205,13 @@ def enroll_auth_factor( totp_issuer=None, totp_user=None, totp_secret=None, - ) -> dict: - ... + ) -> dict: ... - def list_auth_factors(self, user_id) -> WorkOSListResource: - ... + def list_auth_factors(self, user_id) -> WorkOSListResource: ... - def get_invitation(self, invitation_id) -> dict: - ... + def get_invitation(self, invitation_id) -> dict: ... - def find_invitation_by_token(self, invitation_token) -> dict: - ... + def find_invitation_by_token(self, invitation_token) -> dict: ... def list_invitations( self, @@ -255,8 +221,7 @@ def list_invitations( before=None, after=None, order=None, - ) -> WorkOSListResource: - ... + ) -> WorkOSListResource: ... def send_invitation( self, @@ -265,11 +230,9 @@ def send_invitation( expires_in_days=None, inviter_user_id=None, role_slug=None, - ) -> dict: - ... + ) -> dict: ... - def revoke_invitation(self, invitation_id) -> dict: - ... + def revoke_invitation(self, invitation_id) -> dict: ... class UserManagement(UserManagementModule, WorkOSListResource): @@ -1374,17 +1337,17 @@ def enroll_auth_factor( ) factor_and_challenge = {} - factor_and_challenge[ - "authentication_factor" - ] = WorkOSAuthenticationFactorTotp.construct_from_response( - response["authentication_factor"] - ).to_dict() + factor_and_challenge["authentication_factor"] = ( + WorkOSAuthenticationFactorTotp.construct_from_response( + response["authentication_factor"] + ).to_dict() + ) - factor_and_challenge[ - "authentication_challenge" - ] = WorkOSChallenge.construct_from_response( - response["authentication_challenge"] - ).to_dict() + factor_and_challenge["authentication_challenge"] = ( + WorkOSChallenge.construct_from_response( + response["authentication_challenge"] + ).to_dict() + ) return factor_and_challenge diff --git a/workos/utils/http_client.py b/workos/utils/http_client.py index 838dd49b..54dafc5e 100644 --- a/workos/utils/http_client.py +++ b/workos/utils/http_client.py @@ -1,9 +1,8 @@ import asyncio -from typing import Dict, Mapping, Optional, Union, TypedDict +from typing import Mapping, Optional import httpx -from workos.resources.list import ListArgs from workos.utils._base_http_client import BaseHTTPClient from workos.utils.request import REQUEST_METHOD_GET diff --git a/workos/webhooks.py b/workos/webhooks.py index 3e98f9ba..576b2dda 100644 --- a/workos/webhooks.py +++ b/workos/webhooks.py @@ -10,17 +10,13 @@ class WebhooksModule(Protocol): - def verify_event(self, payload, sig_header, secret, tolerance) -> dict: - ... + def verify_event(self, payload, sig_header, secret, tolerance) -> dict: ... - def verify_header(self, event_body, event_signature, secret, tolerance) -> None: - ... + def verify_header(self, event_body, event_signature, secret, tolerance) -> None: ... - def constant_time_compare(self, val1, val2) -> bool: - ... + def constant_time_compare(self, val1, val2) -> bool: ... - def check_timestamp_range(self, time, max_range) -> None: - ... + def check_timestamp_range(self, time, max_range) -> None: ... class Webhooks(WebhooksModule): From f2ecb383f60718d56d18971df4e600df308fd9bb Mon Sep 17 00:00:00 2001 From: pantera Date: Thu, 25 Jul 2024 09:29:05 -0700 Subject: [PATCH 06/18] Add Event types (#289) --- tests/conftest.py | 6 +- tests/test_directory_sync.py | 52 ++++++--- tests/test_events.py | 102 +++--------------- tests/utils/fixtures/mock_directory.py | 6 +- .../mock_directory_activated_payload.py | 30 ++++++ tests/utils/fixtures/mock_event.py | 8 +- tests/utils/list_resource.py | 4 +- workos/directory_sync.py | 1 - workos/events.py | 100 ++++++++--------- workos/resources/directory_sync.py | 16 +-- workos/resources/event.py | 42 -------- workos/resources/event_action.py | 1 - workos/resources/events.py | 45 ++++++++ workos/resources/list.py | 34 ++++-- workos/types/__init__.py | 0 workos/types/directory_sync/__init__.py | 0 .../types/directory_sync/directory_state.py | 25 +++++ workos/types/events/__init__.py | 0 workos/types/events/directory_payload.py | 16 +++ .../directory_payload_with_legacy_fields.py | 15 +++ workos/typing/literals.py | 2 - workos/utils/_base_http_client.py | 1 + workos/utils/http_client.py | 2 +- 23 files changed, 268 insertions(+), 240 deletions(-) create mode 100644 tests/utils/fixtures/mock_directory_activated_payload.py delete mode 100644 workos/resources/event.py create mode 100644 workos/resources/events.py create mode 100644 workos/types/__init__.py create mode 100644 workos/types/directory_sync/__init__.py create mode 100644 workos/types/directory_sync/directory_state.py create mode 100644 workos/types/events/__init__.py create mode 100644 workos/types/events/directory_payload.py create mode 100644 workos/types/events/directory_payload_with_legacy_fields.py diff --git a/tests/conftest.py b/tests/conftest.py index e48f35c9..fddf8fff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,4 @@ -from typing import Mapping, Union +from typing import Mapping, Optional, Union from unittest.mock import AsyncMock, MagicMock import httpx @@ -130,7 +130,7 @@ def inner( http_client: Union[SyncHTTPClient, AsyncHTTPClient], response_dict: dict, status_code: int = 200, - headers: Mapping[str, str] = None, + headers: Optional[Mapping[str, str]] = None, ): mock_class = ( AsyncMock if isinstance(http_client, AsyncHTTPClient) else MagicMock @@ -153,7 +153,7 @@ def inner( http_client: Union[SyncHTTPClient, AsyncHTTPClient], data_list: list, status_code: int = 200, - headers: Mapping[str, str] = None, + headers: Optional[Mapping[str, str]] = None, ): # For convenient index lookup, store the list of object IDs. data_ids = list(map(lambda x: x["id"], data_list)) diff --git a/tests/test_directory_sync.py b/tests/test_directory_sync.py index 5ed9e645..dcdc6cce 100644 --- a/tests/test_directory_sync.py +++ b/tests/test_directory_sync.py @@ -8,6 +8,20 @@ from tests.utils.fixtures.mock_directory_group import MockDirectoryGroup +def api_directory_to_sdk(directory): + # The API returns an active directory as 'linked' + # We normalize this to 'active' in the SDK. This helper function + # does this conversion to make make assertions easier. + if directory["state"] == "linked": + return {**directory, "state": "active"} + else: + return directory + + +def api_directories_to_sdk(directories): + return list(map(lambda x: api_directory_to_sdk(x), directories)) + + class DirectorySyncFixtures: @pytest.fixture def mock_users(self): @@ -73,7 +87,7 @@ def mock_group(self): @pytest.fixture def mock_directories(self): - directory_list = [MockDirectory(id=str(i)).to_dict() for i in range(20)] + directory_list = [MockDirectory(id=str(i)).to_dict() for i in range(10)] return list_response_of(data=directory_list) @pytest.fixture @@ -180,7 +194,9 @@ def test_list_directories(self, mock_directories, mock_http_client_with_response directories = self.directory_sync.list_directories() - assert list_data_to_dicts(directories.data) == mock_directories["data"] + assert list_data_to_dicts(directories.data) == api_directories_to_sdk( + mock_directories["data"] + ) def test_get_directory(self, mock_directory, mock_http_client_with_response): mock_http_client_with_response( @@ -191,7 +207,7 @@ def test_get_directory(self, mock_directory, mock_http_client_with_response): directory = self.directory_sync.get_directory(directory="directory_id") - assert directory.dict() == mock_directory + assert directory.dict() == api_directory_to_sdk(mock_directory) def test_delete_directory(self, mock_http_client_with_response): mock_http_client_with_response( @@ -254,9 +270,9 @@ def test_list_directories_auto_pagination( all_directories.append(directory) assert len(list(all_directories)) == len(mock_directories_multiple_data_pages) - assert ( - list_data_to_dicts(all_directories) - ) == mock_directories_multiple_data_pages + assert (list_data_to_dicts(all_directories)) == api_directories_to_sdk( + mock_directories_multiple_data_pages + ) def test_directory_users_auto_pagination( self, @@ -321,9 +337,9 @@ def test_auto_pagination_honors_limit( all_directories.append(directory) assert len(list(all_directories)) == len(mock_directories_multiple_data_pages) - assert ( - list_data_to_dicts(all_directories) - ) == mock_directories_multiple_data_pages + assert (list_data_to_dicts(all_directories)) == api_directories_to_sdk( + mock_directories_multiple_data_pages + ) @pytest.mark.asyncio @@ -415,7 +431,9 @@ async def test_list_directories( directories = await self.directory_sync.list_directories() - assert list_data_to_dicts(directories.data) == mock_directories["data"] + assert list_data_to_dicts(directories.data) == api_directories_to_sdk( + mock_directories["data"] + ) async def test_get_directory(self, mock_directory, mock_http_client_with_response): mock_http_client_with_response( @@ -426,7 +444,7 @@ async def test_get_directory(self, mock_directory, mock_http_client_with_respons directory = await self.directory_sync.get_directory(directory="directory_id") - assert directory.dict() == mock_directory + assert directory.dict() == api_directory_to_sdk(mock_directory) async def test_delete_directory(self, mock_http_client_with_response): mock_http_client_with_response( @@ -489,9 +507,9 @@ async def test_list_directories_auto_pagination( all_directories.append(directory) assert len(list(all_directories)) == len(mock_directories_multiple_data_pages) - assert ( - list_data_to_dicts(all_directories) - ) == mock_directories_multiple_data_pages + assert (list_data_to_dicts(all_directories)) == api_directories_to_sdk( + mock_directories_multiple_data_pages + ) async def test_directory_users_auto_pagination( self, @@ -556,6 +574,6 @@ async def test_auto_pagination_honors_limit( all_directories.append(directory) assert len(list(all_directories)) == len(mock_directories_multiple_data_pages) - assert ( - list_data_to_dicts(all_directories) - ) == mock_directories_multiple_data_pages + assert (list_data_to_dicts(all_directories)) == api_directories_to_sdk( + mock_directories_multiple_data_pages + ) diff --git a/tests/test_events.py b/tests/test_events.py index ebaca488..bbe6bd68 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -22,17 +22,10 @@ def mock_events(self): events = [MockEvent(id=str(i)).to_dict() for i in range(10)] return { + "object": "list", "data": events, - "metadata": { - "params": { - "events": ["dsync.user.created"], - "limit": 10, - "organization_id": None, - "after": None, - "range_start": None, - "range_end": None, - }, - "method": Events.list_events, + "list_metadata": { + "after": None, }, } @@ -40,44 +33,12 @@ def test_list_events(self, mock_events, mock_http_client_with_response): mock_http_client_with_response( http_client=self.http_client, status_code=200, - response_dict={"data": mock_events["data"]}, + response_dict=mock_events, ) - events = self.events.list_events(events=["dsync.user.created"]) + events = self.events.list_events(events=["dsync.activated"]) - assert events == mock_events - - def test_list_events_returns_metadata( - self, mock_events, mock_http_client_with_response - ): - mock_http_client_with_response( - http_client=self.http_client, - status_code=200, - response_dict={"data": mock_events["data"]}, - ) - - events = self.events.list_events( - events=["dsync.user.created"], - ) - - assert events["metadata"]["params"]["events"] == ["dsync.user.created"] - - def test_list_events_with_organization_id_returns_metadata( - self, mock_events, mock_http_client_with_response - ): - mock_http_client_with_response( - http_client=self.http_client, - status_code=200, - response_dict={"data": mock_events["data"]}, - ) - - events = self.events.list_events( - events=["dsync.user.created"], - organization_id="org_1234", - ) - - assert events["metadata"]["params"]["organization_id"] == "org_1234" - assert events["metadata"]["params"]["events"] == ["dsync.user.created"] + assert events.dict() == mock_events @pytest.mark.asyncio @@ -98,17 +59,10 @@ def mock_events(self): events = [MockEvent(id=str(i)).to_dict() for i in range(10)] return { + "object": "list", "data": events, - "metadata": { - "params": { - "events": ["dsync.user.created"], - "limit": 10, - "organization_id": None, - "after": None, - "range_start": None, - "range_end": None, - }, - "method": AsyncEvents.list_events, + "list_metadata": { + "after": None, }, } @@ -116,41 +70,9 @@ async def test_list_events(self, mock_events, mock_http_client_with_response): mock_http_client_with_response( http_client=self.http_client, status_code=200, - response_dict={"data": mock_events["data"]}, + response_dict=mock_events, ) - events = await self.events.list_events(events=["dsync.user.created"]) - - assert events == mock_events - - async def test_list_events_returns_metadata( - self, mock_events, mock_http_client_with_response - ): - mock_http_client_with_response( - http_client=self.http_client, - status_code=200, - response_dict={"data": mock_events["data"]}, - ) - - events = await self.events.list_events( - events=["dsync.user.created"], - ) - - assert events["metadata"]["params"]["events"] == ["dsync.user.created"] - - async def test_list_events_with_organization_id_returns_metadata( - self, mock_events, mock_http_client_with_response - ): - mock_http_client_with_response( - http_client=self.http_client, - status_code=200, - response_dict={"data": mock_events["data"]}, - ) - - events = await self.events.list_events( - events=["dsync.user.created"], - organization_id="org_1234", - ) + events = await self.events.list_events(events=["dsync.activated"]) - assert events["metadata"]["params"]["organization_id"] == "org_1234" - assert events["metadata"]["params"]["events"] == ["dsync.user.created"] + assert events.dict() == mock_events diff --git a/tests/utils/fixtures/mock_directory.py b/tests/utils/fixtures/mock_directory.py index 9d1cd391..7abf6da4 100644 --- a/tests/utils/fixtures/mock_directory.py +++ b/tests/utils/fixtures/mock_directory.py @@ -8,8 +8,9 @@ def __init__(self, id): self.object = "directory" self.id = id self.organization_id = "organization_id" - self.domain = "crashlanding.com" - self.name = "Ri Jeong Hyeok" + self.external_key = "ext_123" + self.domain = "somefakedomain.com" + self.name = "Some fake name" self.state = "linked" self.type = "gsuite directory" self.created_at = now @@ -20,6 +21,7 @@ def __init__(self, id): "id", "domain", "name", + "external_key", "organization_id", "state", "type", diff --git a/tests/utils/fixtures/mock_directory_activated_payload.py b/tests/utils/fixtures/mock_directory_activated_payload.py new file mode 100644 index 00000000..b4a0eeb8 --- /dev/null +++ b/tests/utils/fixtures/mock_directory_activated_payload.py @@ -0,0 +1,30 @@ +import datetime +from workos.resources.base import WorkOSBaseResource + + +class MockDirectoryActivatedPayload(WorkOSBaseResource): + def __init__(self, id): + now = datetime.datetime.now().isoformat() + self.object = "directory" + self.id = id + self.organization_id = "organization_id" + self.external_key = "ext_123" + self.domains = [] + self.name = "Some fake name" + self.state = "active" + self.type = "gsuite directory" + self.created_at = now + self.updated_at = now + + OBJECT_FIELDS = [ + "object", + "id", + "name", + "external_key", + "domains", + "organization_id", + "state", + "type", + "created_at", + "updated_at", + ] diff --git a/tests/utils/fixtures/mock_event.py b/tests/utils/fixtures/mock_event.py index 2992d398..1379f6d5 100644 --- a/tests/utils/fixtures/mock_event.py +++ b/tests/utils/fixtures/mock_event.py @@ -1,4 +1,8 @@ import datetime +from tests.utils.fixtures.mock_directory import MockDirectory +from tests.utils.fixtures.mock_directory_activated_payload import ( + MockDirectoryActivatedPayload, +) from workos.resources.base import WorkOSBaseResource @@ -6,8 +10,8 @@ class MockEvent(WorkOSBaseResource): def __init__(self, id): self.object = "event" self.id = id - self.event = "dsync.user.created" - self.data = {"id": "event_01234ABCD", "organization_id": "org_1234"} + self.event = "dsync.activated" + self.data = MockDirectoryActivatedPayload("dir_1234").to_dict() self.created_at = datetime.datetime.now().isoformat() OBJECT_FIELDS = [ diff --git a/tests/utils/list_resource.py b/tests/utils/list_resource.py index 0cd8f94f..fef4a0ed 100644 --- a/tests/utils/list_resource.py +++ b/tests/utils/list_resource.py @@ -1,7 +1,7 @@ -from typing import Dict, List, Optional +from typing import Dict, Optional, Sequence -def list_data_to_dicts(list_data: List): +def list_data_to_dicts(list_data: Sequence): return list(map(lambda x: x.dict(), list_data)) diff --git a/workos/directory_sync.py b/workos/directory_sync.py index ef8902c7..678cf422 100644 --- a/workos/directory_sync.py +++ b/workos/directory_sync.py @@ -4,7 +4,6 @@ from workos.utils.http_client import AsyncHTTPClient, SyncHTTPClient from workos.utils.pagination_order import PaginationOrder from workos.utils.request import REQUEST_METHOD_DELETE, REQUEST_METHOD_GET - from workos.utils.validation import DIRECTORY_SYNC_MODULE, validate_settings from workos.resources.directory_sync import ( DirectoryGroup, diff --git a/workos/events.py b/workos/events.py index 09346b55..809c7315 100644 --- a/workos/events.py +++ b/workos/events.py @@ -3,27 +3,41 @@ import workos from workos.typing.sync_or_async import SyncOrAsync from workos.utils.request import REQUEST_METHOD_GET +from workos.resources.events import Event, EventType from workos.utils.http_client import AsyncHTTPClient, SyncHTTPClient from workos.utils.validation import EVENTS_MODULE, validate_settings -from workos.resources.list import WorkOSListResource +from workos.resources.list import ( + ListArgs, + ListPage, + WorkOsListResource, +) RESPONSE_LIMIT = 10 +class EventsListFilters(ListArgs, total=False): + events: List[EventType] + organization_id: Optional[str] + range_start: Optional[str] + range_end: Optional[str] + + +EventsListResource = WorkOsListResource[Event, EventsListFilters] + + class EventsModule(Protocol): def list_events( self, - # TODO: Use event Literal type when available - events: List[str], - limit: Optional[int] = None, + events: List[EventType], + limit: int = RESPONSE_LIMIT, organization_id: Optional[str] = None, after: Optional[str] = None, range_start: Optional[str] = None, range_end: Optional[str] = None, - ) -> SyncOrAsync[dict]: ... + ) -> SyncOrAsync[EventsListResource]: ... -class Events(EventsModule, WorkOSListResource): +class Events(EventsModule): """Offers methods through the WorkOS Events service.""" _http_client: SyncHTTPClient @@ -34,14 +48,13 @@ def __init__(self, http_client: SyncHTTPClient): def list_events( self, - # TODO: Use event Literal type when available - events: List[str], - limit=None, - organization_id=None, - after=None, - range_start=None, - range_end=None, - ) -> dict: + events: List[EventType], + limit: int = RESPONSE_LIMIT, + organization: Optional[str] = None, + after: Optional[str] = None, + range_start: Optional[str] = None, + range_end: Optional[str] = None, + ) -> EventsListResource: """Gets a list of Events . Kwargs: events (list): Filter to only return events of particular types. (Optional) @@ -56,15 +69,11 @@ def list_events( dict: Events response from WorkOS. """ - if limit is None: - limit = RESPONSE_LIMIT - default_limit = True - - params = { + params: EventsListFilters = { "events": events, "limit": limit, "after": after, - "organization_id": organization_id, + "organization_id": organization, "range_start": range_start, "range_end": range_end, } @@ -75,22 +84,14 @@ def list_events( params=params, token=workos.api_key, ) - - if "default_limit" in locals(): - if "metadata" in response and "params" in response["metadata"]: - response["metadata"]["params"]["default_limit"] = default_limit - else: - response["metadata"] = {"params": {"default_limit": default_limit}} - - response["metadata"] = { - "params": params, - "method": Events.list_events, - } - - return response + return WorkOsListResource( + list_method=self.list_events, + list_args=params, + **ListPage[Event](**response).model_dump(exclude_unset=True), + ) -class AsyncEvents(EventsModule, WorkOSListResource): +class AsyncEvents(EventsModule): """Offers methods through the WorkOS Events service.""" _http_client: AsyncHTTPClient @@ -101,14 +102,13 @@ def __init__(self, http_client: AsyncHTTPClient): async def list_events( self, - # TODO: Use event Literal type when available - events: List[str], - limit: Optional[int] = None, + events: List[EventType], + limit: int = RESPONSE_LIMIT, organization_id: Optional[str] = None, after: Optional[str] = None, range_start: Optional[str] = None, range_end: Optional[str] = None, - ) -> dict: + ) -> EventsListResource: """Gets a list of Events . Kwargs: events (list): Filter to only return events of particular types. (Optional) @@ -122,12 +122,7 @@ async def list_events( Returns: dict: Events response from WorkOS. """ - - if limit is None: - limit = RESPONSE_LIMIT - default_limit = True - - params = { + params: EventsListFilters = { "events": events, "limit": limit, "after": after, @@ -143,15 +138,8 @@ async def list_events( token=workos.api_key, ) - if "default_limit" in locals(): - if "metadata" in response and "params" in response["metadata"]: - response["metadata"]["params"]["default_limit"] = default_limit - else: - response["metadata"] = {"params": {"default_limit": default_limit}} - - response["metadata"] = { - "params": params, - "method": AsyncEvents.list_events, - } - - return response + return WorkOsListResource( + list_method=self.list_events, + list_args=params, + **ListPage[Event](**response).model_dump(exclude_unset=True), + ) diff --git a/workos/resources/directory_sync.py b/workos/resources/directory_sync.py index a336ddde..b053a866 100644 --- a/workos/resources/directory_sync.py +++ b/workos/resources/directory_sync.py @@ -1,15 +1,8 @@ from typing import List, Optional, Literal from workos.resources.workos_model import WorkOSModel +from workos.types.directory_sync.directory_state import DirectoryState from workos.typing.literals import LiteralOrUntyped -DirectoryState = Literal[ - "linked", - "unlinked", - "validating", - "deleting", - "invalid_credentials", -] - DirectoryType = Literal[ "azure scim v2.0", "bamboohr", @@ -34,16 +27,17 @@ class Directory(WorkOSModel): - # Should this be WorkOSDirectory? """Representation of a Directory Response as returned by WorkOS through the Directory Sync feature. Attributes: OBJECT_FIELDS (list): List of fields a Directory is comprised of. """ + id: str object: Literal["directory"] domain: Optional[str] = None name: str organization_id: str + external_key: str state: LiteralOrUntyped[DirectoryState] type: LiteralOrUntyped[DirectoryType] created_at: str @@ -51,12 +45,12 @@ class Directory(WorkOSModel): class DirectoryGroup(WorkOSModel): - # Should this be WorkOSDirectoryGroup? """Representation of a Directory Group as returned by WorkOS through the Directory Sync feature. Attributes: OBJECT_FIELDS (list): List of fields a DirectoryGroup is comprised of. """ + id: str object: Literal["directory_group"] idp_id: str @@ -82,12 +76,12 @@ class Role(WorkOSModel): class DirectoryUser(WorkOSModel): - # Should this be WorkOSDirectoryUser? """Representation of a Directory User as returned by WorkOS through the Directory Sync feature. Attributes: OBJECT_FIELDS (list): List of fields a DirectoryUser is comprised of. """ + id: str object: Literal["directory_user"] idp_id: str diff --git a/workos/resources/event.py b/workos/resources/event.py deleted file mode 100644 index 75a7844c..00000000 --- a/workos/resources/event.py +++ /dev/null @@ -1,42 +0,0 @@ -from workos.resources.base import WorkOSBaseResource -from workos.resources.event_action import WorkOSEventAction - - -class WorkOSEvent(WorkOSBaseResource): - """Representation of an Event as returned by WorkOS through the Audit Trail feature. - - Attributes: - OBJECT_FIELDS (list): List of fields a WorkOSEvent is comprised of. - """ - - OBJECT_FIELDS = [ - "id", - "group", - "location", - "latitude", - "longitude", - "type", - "actor_name", - "actor_id", - "target_name", - "target_id", - "metadata", - "occurred_at", - ] - - @classmethod - def construct_from_response(cls, response): - event = super(WorkOSEvent, cls).construct_from_response(response) - - event_action = WorkOSEventAction.construct_from_response(response["action"]) - event.action = event_action - - return event - - def to_dict(self): - event_dict = super(WorkOSEvent, self).to_dict() - - event_action_dict = self.action.to_dict() - event_dict["action"] = event_action_dict - - return event_dict diff --git a/workos/resources/event_action.py b/workos/resources/event_action.py index 6b0b65ed..4658adb2 100644 --- a/workos/resources/event_action.py +++ b/workos/resources/event_action.py @@ -3,7 +3,6 @@ class WorkOSEventAction(WorkOSBaseResource): """Representation of an Event Action as returned by WorkOS through the Audit Trail feature. - Attributes: OBJECT_FIELDS (list): List of fields a WorkOSEventAction is comprised of. """ diff --git a/workos/resources/events.py b/workos/resources/events.py new file mode 100644 index 00000000..8f39ba6d --- /dev/null +++ b/workos/resources/events.py @@ -0,0 +1,45 @@ +from typing import Generic, Literal, TypeVar, Union +from typing_extensions import Annotated +from pydantic import Field +from workos.resources.workos_model import WorkOSModel +from workos.types.events.directory_payload import DirectoryPayload +from workos.types.events.directory_payload_with_legacy_fields import ( + DirectoryPayloadWithLegacyFields, +) +from workos.typing.literals import LiteralOrUntyped + +EventType = Literal["dsync.activated", "dsync.deleted"] +EventTypeDiscriminator = TypeVar("EventTypeDiscriminator", bound=EventType) +EventPayload = TypeVar( + "EventPayload", DirectoryPayload, DirectoryPayloadWithLegacyFields +) + + +class EventModel(WorkOSModel, Generic[EventTypeDiscriminator, EventPayload]): + # TODO: fix these docs + """Representation of an Event returned from the Events API or via Webhook. + Attributes: + OBJECT_FIELDS (list): List of fields an Event is comprised of. + """ + + id: str + object: Literal["event"] + event: LiteralOrUntyped[EventTypeDiscriminator] + data: EventPayload + created_at: str + + +class DirectoryActivatedEvent( + EventModel[Literal["dsync.activated"], DirectoryPayloadWithLegacyFields] +): + event: Literal["dsync.activated"] + + +class DirectoryDeletedEvent(EventModel[Literal["dsync.deleted"], DirectoryPayload]): + event: Literal["dsync.deleted"] + + +Event = Annotated[ + Union[DirectoryActivatedEvent, DirectoryDeletedEvent], + Field(..., discriminator="event"), +] diff --git a/workos/resources/list.py b/workos/resources/list.py index 1bdd5fed..e714f3f2 100644 --- a/workos/resources/list.py +++ b/workos/resources/list.py @@ -2,6 +2,7 @@ from typing import ( AsyncIterator, Awaitable, + Dict, List, Literal, TypeVar, @@ -10,10 +11,12 @@ Iterator, Optional, Union, + cast, ) -from typing_extensions import TypedDict +from typing_extensions import Required, TypedDict from workos.resources.base import WorkOSBaseResource from workos.resources.directory_sync import Directory, DirectoryGroup, DirectoryUser +from workos.resources.events import Event from workos.resources.organizations import Organization from pydantic import BaseModel, Field from workos.resources.workos_model import WorkOSModel @@ -117,11 +120,15 @@ def auto_paging_iter(self): Directory, DirectoryGroup, DirectoryUser, + Event, ) -class ListMetadata(BaseModel): +class ListAfterMetadata(BaseModel): after: Optional[str] = None + + +class ListMetadata(ListAfterMetadata): before: Optional[str] = None @@ -131,14 +138,15 @@ class ListPage(WorkOSModel, Generic[ListableResource]): list_metadata: ListMetadata -class ListArgs(TypedDict): - limit: int +class ListArgs(TypedDict, total=False): before: Optional[str] after: Optional[str] - order: Literal["asc", "desc"] + limit: Required[int] + order: Optional[Literal["asc", "desc"]] ListAndFilterParams = TypeVar("ListAndFilterParams", bound=ListArgs) +ListMetadataType = TypeVar("ListMetadataType", ListAfterMetadata, ListMetadata) class BaseWorkOsListResource( @@ -147,16 +155,22 @@ class BaseWorkOsListResource( ): object: Literal["list"] data: List[ListableResource] - list_metadata: ListMetadata + list_metadata: Union[ListAfterMetadata, ListMetadata] list_method: Callable = Field(exclude=True) list_args: ListAndFilterParams = Field(exclude=True) def _parse_params(self): - fixed_pagination_params = { - "order": self.list_args["order"], - "limit": self.list_args["limit"], - } + fixed_pagination_params = cast( + # Type hints consider this a mismatch because it assume the dictionary is dict[str, int] + Dict[str, Union[int, str, None]], + { + "limit": self.list_args["limit"], + }, + ) + if "order" in self.list_args: + fixed_pagination_params["order"] = self.list_args["order"] + # Omit common list parameters filter_params = { k: v diff --git a/workos/types/__init__.py b/workos/types/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/workos/types/directory_sync/__init__.py b/workos/types/directory_sync/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/workos/types/directory_sync/directory_state.py b/workos/types/directory_sync/directory_state.py new file mode 100644 index 00000000..e4d72c06 --- /dev/null +++ b/workos/types/directory_sync/directory_state.py @@ -0,0 +1,25 @@ +from typing import Any, Literal +from pydantic import BeforeValidator, ValidationInfo +from typing_extensions import Annotated + + +ApiDirectoryState = Literal[ + "active", + "unlinked", + "validating", + "deleting", + "invalid_credentials", +] + + +def convert_linked_to_active(value: Any, info: ValidationInfo) -> Any: + if isinstance(value, str) and value == "linked": + return "active" + else: + return value + + +DirectoryState = Annotated[ + ApiDirectoryState, + BeforeValidator(convert_linked_to_active), +] diff --git a/workos/types/events/__init__.py b/workos/types/events/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/workos/types/events/directory_payload.py b/workos/types/events/directory_payload.py new file mode 100644 index 00000000..c7f1cf06 --- /dev/null +++ b/workos/types/events/directory_payload.py @@ -0,0 +1,16 @@ +from typing import Literal +from workos.resources.directory_sync import DirectoryType +from workos.resources.workos_model import WorkOSModel +from workos.types.directory_sync.directory_state import DirectoryState +from workos.typing.literals import LiteralOrUntyped + + +class DirectoryPayload(WorkOSModel): + id: str + name: str + state: LiteralOrUntyped[DirectoryState] + type: LiteralOrUntyped[DirectoryType] + organization_id: str + created_at: str + updated_at: str + object: Literal["directory"] diff --git a/workos/types/events/directory_payload_with_legacy_fields.py b/workos/types/events/directory_payload_with_legacy_fields.py new file mode 100644 index 00000000..74850dd8 --- /dev/null +++ b/workos/types/events/directory_payload_with_legacy_fields.py @@ -0,0 +1,15 @@ +from typing import List, Literal +from workos.resources.workos_model import WorkOSModel +from workos.types.events.directory_payload import DirectoryPayload +from workos.typing.literals import LiteralOrUntyped + + +class MinimalOrganizationDomain(WorkOSModel): + id: str + organization_id: str + object: Literal["organization_domain"] + + +class DirectoryPayloadWithLegacyFields(DirectoryPayload): + domains: List[MinimalOrganizationDomain] + external_key: str diff --git a/workos/typing/literals.py b/workos/typing/literals.py index 4cc70581..3180b391 100644 --- a/workos/typing/literals.py +++ b/workos/typing/literals.py @@ -9,8 +9,6 @@ ) from workos.typing.untyped_literal import UntypedLiteral -# Identical to the enums approach, except typed for a literal of string. - def convert_unknown_literal_to_untyped_literal( value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo diff --git a/workos/utils/_base_http_client.py b/workos/utils/_base_http_client.py index 0baeca9d..91d4dccb 100644 --- a/workos/utils/_base_http_client.py +++ b/workos/utils/_base_http_client.py @@ -1,5 +1,6 @@ import platform from typing import ( + Mapping, cast, Dict, Generic, diff --git a/workos/utils/http_client.py b/workos/utils/http_client.py index 54dafc5e..b1929c1e 100644 --- a/workos/utils/http_client.py +++ b/workos/utils/http_client.py @@ -150,7 +150,7 @@ async def request( self, path: str, method: Optional[str] = REQUEST_METHOD_GET, - params: Optional[dict] = None, + params: Optional[Mapping] = None, headers: Optional[dict] = None, token: Optional[str] = None, ) -> dict: From 2894241355670aff633e5f940c3256b301e3a627 Mon Sep 17 00:00:00 2001 From: Matt Dzwonczyk <9063128+mattgd@users.noreply.github.com> Date: Thu, 25 Jul 2024 15:04:30 -0500 Subject: [PATCH 07/18] Add typing and async support for SSO (#291) --- tests/conftest.py | 35 +- tests/test_client.py | 35 ++ tests/test_directory_sync.py | 2 - tests/test_organizations.py | 5 +- tests/test_sso.py | 634 ++++++++++-------------- tests/utils/fixtures/mock_connection.py | 20 +- workos/async_client.py | 5 +- workos/client.py | 2 +- workos/directory_sync.py | 29 +- workos/events.py | 10 +- workos/organizations.py | 6 +- workos/resources/list.py | 4 +- workos/resources/sso.py | 116 ++--- workos/sso.py | 426 ++++++++-------- workos/user_management.py | 9 +- workos/utils/_base_http_client.py | 14 + workos/utils/connection_types.py | 83 ++-- workos/utils/request.py | 6 +- workos/utils/sso_provider_types.py | 8 - 19 files changed, 677 insertions(+), 772 deletions(-) delete mode 100644 workos/utils/sso_provider_types.py diff --git a/tests/conftest.py b/tests/conftest.py index fddf8fff..bf96ee24 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -128,7 +128,7 @@ def mock(*args, **kwargs): def mock_http_client_with_response(monkeypatch): def inner( http_client: Union[SyncHTTPClient, AsyncHTTPClient], - response_dict: dict, + response_dict: Optional[dict] = None, status_code: int = 200, headers: Optional[Mapping[str, str]] = None, ): @@ -145,6 +145,39 @@ def inner( return inner +@pytest.fixture +def capture_and_mock_http_client_request(monkeypatch): + def inner( + http_client: Union[SyncHTTPClient, AsyncHTTPClient], + response_dict: dict, + status_code: int = 200, + headers: Optional[Mapping[str, str]] = None, + ): + request_args = [] + request_kwargs = {} + + def capture_and_mock(*args, **kwargs): + request_args.extend(args) + request_kwargs.update(kwargs) + + return httpx.Response( + status_code=status_code, + headers=headers, + json=response_dict, + ) + + mock_class = ( + AsyncMock if isinstance(http_client, AsyncHTTPClient) else MagicMock + ) + mock = mock_class(side_effect=capture_and_mock) + + monkeypatch.setattr(http_client._client, "request", mock) + + return (request_args, request_kwargs) + + return inner + + @pytest.fixture def mock_pagination_request_for_http_client(monkeypatch): # Mocking pagination correctly requires us to index into a list of data diff --git a/tests/test_client.py b/tests/test_client.py index d7eabea9..177b86a2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -171,3 +171,38 @@ def test_initialize_events_missing_api_key(self): message = str(ex) assert "api_key" in message + + def test_initialize_sso(self, set_api_key_and_client_id): + assert bool(async_client.sso) + + def test_initialize_sso_missing_api_key(self, set_client_id): + with pytest.raises(ConfigurationException) as ex: + async_client.sso + + message = str(ex) + + assert "api_key" in message + assert "client_id" not in message + + def test_initialize_sso_missing_client_id(self, set_api_key): + with pytest.raises(ConfigurationException) as ex: + async_client.sso + + message = str(ex) + + assert "client_id" in message + assert "api_key" not in message + + def test_initialize_sso_missing_api_key_and_client_id(self): + with pytest.raises(ConfigurationException) as ex: + async_client.sso + + message = str(ex) + + assert all( + setting in message + for setting in ( + "api_key", + "client_id", + ) + ) diff --git a/tests/test_directory_sync.py b/tests/test_directory_sync.py index dcdc6cce..5b36aa94 100644 --- a/tests/test_directory_sync.py +++ b/tests/test_directory_sync.py @@ -213,7 +213,6 @@ def test_delete_directory(self, mock_http_client_with_response): mock_http_client_with_response( http_client=self.http_client, status_code=202, - response_dict=None, headers={"content-type": "text/plain; charset=utf-8"}, ) @@ -450,7 +449,6 @@ async def test_delete_directory(self, mock_http_client_with_response): mock_http_client_with_response( http_client=self.http_client, status_code=202, - response_dict=None, headers={"content-type": "text/plain; charset=utf-8"}, ) diff --git a/tests/test_organizations.py b/tests/test_organizations.py index e559cb33..3eb7bb81 100644 --- a/tests/test_organizations.py +++ b/tests/test_organizations.py @@ -1,11 +1,8 @@ import datetime -from typing import Dict, List, Union, cast import pytest -import requests -from tests.conftest import MockResponse -from tests.utils.list_resource import list_data_to_dicts, list_response_of +from tests.utils.list_resource import list_data_to_dicts from workos.organizations import Organizations from tests.utils.fixtures.mock_organization import MockOrganization diff --git a/tests/test_sso.py b/tests/test_sso.py index 70649fd7..d5bac063 100644 --- a/tests/test_sso.py +++ b/tests/test_sso.py @@ -1,30 +1,22 @@ import json + from six.moves.urllib.parse import parse_qsl, urlparse import pytest + +from tests.utils.list_resource import list_data_to_dicts, list_response_of import workos -from workos.sso import SSO -from workos.utils.connection_types import ConnectionType -from workos.utils.sso_provider_types import SsoProviderType +from workos.sso import SSO, AsyncSSO +from workos.resources.sso import SsoProviderType +from workos.utils.http_client import AsyncHTTPClient, SyncHTTPClient from workos.utils.request import RESPONSE_TYPE_CODE from tests.utils.fixtures.mock_connection import MockConnection -class TestSSO(object): - @pytest.fixture - def setup_with_client_id(self, set_api_key_and_client_id): - self.sso = SSO() - self.provider = SsoProviderType.GoogleOAuth - self.customer_domain = "workos.com" - self.login_hint = "foo@workos.com" - self.redirect_uri = "https://localhost/auth/callback" - self.state = json.dumps({"things": "with_stuff"}) - self.connection = "connection_123" - self.organization = "organization_123" - self.setup_completed = True - +class SSOFixtures: @pytest.fixture def mock_profile(self): return { + "object": "profile", "id": "prof_01DWAS7ZQWM70PV93BFV1V78QV", "email": "demo@workos-okta.com", "first_name": "WorkOS", @@ -45,6 +37,7 @@ def mock_profile(self): @pytest.fixture def mock_magic_link_profile(self): return { + "object": "profile", "id": "prof_01DWAS7ZQWM70PV93BFV1V78QV", "email": "demo@workos-magic-link.com", "organization_id": None, @@ -63,221 +56,64 @@ def mock_connection(self): @pytest.fixture def mock_connections(self): - connection_list = [MockConnection(id=str(i)).to_dict() for i in range(5000)] - - return { - "data": connection_list, - "list_metadata": {"before": None, "after": None}, - "metadata": { - "params": { - "domains": None, - "limit": 4, - "before": None, - "after": None, - "order": None, - "default_limit": True, - }, - "method": SSO.list_connections, - }, - } - - @pytest.fixture - def mock_connections_with_limit(self): - connection_list = [MockConnection(id=str(i)).to_dict() for i in range(4)] - - return { - "data": connection_list, - "list_metadata": {"before": None, "after": None}, - "metadata": { - "params": { - "connection_type": None, - "domain": None, - "organization_id": None, - "limit": 4, - "before": None, - "after": None, - "order": None, - }, - "method": SSO.list_connections, - }, - } - - @pytest.fixture - def mock_connections_with_limit_v2(self, set_api_key_and_client_id): - connection_list = [MockConnection(id=str(i)).to_dict() for i in range(4)] - - dict_response = { - "data": connection_list, - "list_metadata": {"before": None, "after": None}, - "metadata": { - "params": { - "connection_type": None, - "domain": None, - "organization_id": None, - "limit": 4, - "before": None, - "after": None, - "order": None, - }, - "method": SSO.list_connections_v2, - }, - } - return SSO.construct_from_response(dict_response) - - @pytest.fixture - def mock_connections_with_default_limit(self): connection_list = [MockConnection(id=str(i)).to_dict() for i in range(10)] - return { - "data": connection_list, - "list_metadata": {"before": None, "after": "conn_xxx"}, - "metadata": { - "params": { - "connection_type": None, - "domain": None, - "organization_id": None, - "limit": 4, - "before": None, - "after": None, - "order": None, - "default_limit": True, - }, - "method": SSO.list_connections, - }, - } + return list_response_of(data=connection_list) @pytest.fixture - def mock_connections_with_default_limit_v2(self, setup_with_client_id): - connection_list = [MockConnection(id=str(i)).to_dict() for i in range(10)] + def mock_connections_multiple_data_pages(self): + return [MockConnection(id=str(i)).to_dict() for i in range(40)] - dict_response = { - "data": connection_list, - "list_metadata": {"before": None, "after": "conn_xxx"}, - "metadata": { - "params": { - "connection_type": None, - "domain": None, - "organization_id": None, - "limit": 4, - "before": None, - "after": None, - "order": None, - "default_limit": True, - }, - "method": SSO.list_connections_v2, - }, - } - return self.sso.construct_from_response(dict_response) - @pytest.fixture - def mock_connections_pagination_response(self): - connection_list = [MockConnection(id=str(i)).to_dict() for i in range(4990)] +class TestSSOBase(SSOFixtures): + provider: SsoProviderType - return { - "data": connection_list, - "list_metadata": {"before": None, "after": None}, - "metadata": { - "params": { - "connection_type": None, - "domain": None, - "organization_id": None, - "limit": None, - "before": None, - "after": None, - "order": None, - "default_limit": True, - }, - "method": SSO.list_connections, - }, - } + @pytest.fixture(autouse=True) + def setup(self, set_api_key_and_client_id): + self.http_client = SyncHTTPClient( + base_url="https://api.workos.test", version="test" + ) + self.sso = SSO(http_client=self.http_client) + self.provider = "GoogleOAuth" + self.customer_domain = "workos.com" + self.login_hint = "foo@workos.com" + self.redirect_uri = "https://localhost/auth/callback" + self.authorization_state = json.dumps({"things": "with_stuff"}) + self.connection_id = "connection_123" + self.organization_id = "organization_123" + self.setup_completed = True - def test_authorization_url_throws_value_error_with_missing_connection_domain_and_provider( - self, setup_with_client_id + def test_authorization_url_throws_value_error_with_missing_connection_organization_and_provider( + self, ): with pytest.raises(ValueError, match=r"Incomplete arguments.*"): self.sso.get_authorization_url( - redirect_uri=self.redirect_uri, state=self.state - ) - - @pytest.mark.parametrize( - "invalid_provider", - [ - 123, - SsoProviderType, - True, - False, - {"provider": "GoogleOAuth"}, - ["GoogleOAuth"], - ], - ) - def test_authorization_url_throws_value_error_with_incorrect_provider_type( - self, setup_with_client_id, invalid_provider - ): - with pytest.raises( - ValueError, match="'provider' must be of type SsoProviderType" - ): - self.sso.get_authorization_url( - provider=invalid_provider, - redirect_uri=self.redirect_uri, - state=self.state, - ) - - def test_authorization_url_throws_value_error_without_redirect_uri( - self, setup_with_client_id - ): - with pytest.raises( - ValueError, match="Incomplete arguments. Need to specify a 'redirect_uri'." - ): - self.sso.get_authorization_url( - connection=self.connection, - login_hint=self.login_hint, - state=self.state, + redirect_uri=self.redirect_uri, state=self.authorization_state ) - def test_authorization_url_has_expected_query_params_with_provider( - self, setup_with_client_id - ): + def test_authorization_url_has_expected_query_params_with_provider(self): authorization_url = self.sso.get_authorization_url( - provider=self.provider, redirect_uri=self.redirect_uri, state=self.state - ) - - parsed_url = urlparse(authorization_url) - - assert dict(parse_qsl(parsed_url.query)) == { - "provider": str(self.provider.value), - "client_id": workos.client_id, - "redirect_uri": self.redirect_uri, - "response_type": RESPONSE_TYPE_CODE, - "state": self.state, - } - - def test_authorization_url_has_expected_query_params_with_domain( - self, setup_with_client_id - ): - authorization_url = self.sso.get_authorization_url( - domain=self.customer_domain, + provider=self.provider, redirect_uri=self.redirect_uri, - state=self.state, + state=self.authorization_state, ) parsed_url = urlparse(authorization_url) assert dict(parse_qsl(parsed_url.query)) == { - "domain": self.customer_domain, + "provider": self.provider, "client_id": workos.client_id, "redirect_uri": self.redirect_uri, "response_type": RESPONSE_TYPE_CODE, - "state": self.state, + "state": self.authorization_state, } - def test_authorization_url_has_expected_query_params_with_domain_hint( - self, setup_with_client_id - ): + def test_authorization_url_has_expected_query_params_with_domain_hint(self): authorization_url = self.sso.get_authorization_url( - connection=self.connection, + connection_id=self.connection_id, domain_hint=self.customer_domain, redirect_uri=self.redirect_uri, - state=self.state, + state=self.authorization_state, ) parsed_url = urlparse(authorization_url) @@ -286,19 +122,17 @@ def test_authorization_url_has_expected_query_params_with_domain_hint( "domain_hint": self.customer_domain, "client_id": workos.client_id, "redirect_uri": self.redirect_uri, - "connection": self.connection, + "connection": self.connection_id, "response_type": RESPONSE_TYPE_CODE, - "state": self.state, + "state": self.authorization_state, } - def test_authorization_url_has_expected_query_params_with_login_hint( - self, setup_with_client_id - ): + def test_authorization_url_has_expected_query_params_with_login_hint(self): authorization_url = self.sso.get_authorization_url( - connection=self.connection, + connection_id=self.connection_id, login_hint=self.login_hint, redirect_uri=self.redirect_uri, - state=self.state, + state=self.authorization_state, ) parsed_url = urlparse(authorization_url) @@ -307,93 +141,108 @@ def test_authorization_url_has_expected_query_params_with_login_hint( "login_hint": self.login_hint, "client_id": workos.client_id, "redirect_uri": self.redirect_uri, - "connection": self.connection, + "connection": self.connection_id, "response_type": RESPONSE_TYPE_CODE, - "state": self.state, + "state": self.authorization_state, } - def test_authorization_url_has_expected_query_params_with_connection( - self, setup_with_client_id - ): + def test_authorization_url_has_expected_query_params_with_connection(self): authorization_url = self.sso.get_authorization_url( - connection=self.connection, + connection_id=self.connection_id, redirect_uri=self.redirect_uri, - state=self.state, + state=self.authorization_state, ) parsed_url = urlparse(authorization_url) assert dict(parse_qsl(parsed_url.query)) == { - "connection": self.connection, + "connection": self.connection_id, "client_id": workos.client_id, "redirect_uri": self.redirect_uri, "response_type": RESPONSE_TYPE_CODE, - "state": self.state, + "state": self.authorization_state, } def test_authorization_url_with_string_provider_has_expected_query_params_with_organization( - self, setup_with_client_id + self, ): authorization_url = self.sso.get_authorization_url( provider=self.provider, - organization=self.organization, + organization_id=self.organization_id, redirect_uri=self.redirect_uri, - state=self.state, + state=self.authorization_state, ) parsed_url = urlparse(authorization_url) assert dict(parse_qsl(parsed_url.query)) == { - "organization": self.organization, - "provider": self.provider.value, + "organization": self.organization_id, + "provider": self.provider, "client_id": workos.client_id, "redirect_uri": self.redirect_uri, "response_type": RESPONSE_TYPE_CODE, - "state": self.state, + "state": self.authorization_state, } - def test_authorization_url_has_expected_query_params_with_organization( - self, setup_with_client_id - ): + def test_authorization_url_has_expected_query_params_with_organization(self): authorization_url = self.sso.get_authorization_url( - organization=self.organization, + organization_id=self.organization_id, redirect_uri=self.redirect_uri, - state=self.state, + state=self.authorization_state, ) parsed_url = urlparse(authorization_url) assert dict(parse_qsl(parsed_url.query)) == { - "organization": self.organization, + "organization": self.organization_id, "client_id": workos.client_id, "redirect_uri": self.redirect_uri, "response_type": RESPONSE_TYPE_CODE, - "state": self.state, + "state": self.authorization_state, } - def test_authorization_url_has_expected_query_params_with_domain_and_provider( - self, setup_with_client_id + def test_authorization_url_has_expected_query_params_with_organization_and_provider( + self, ): authorization_url = self.sso.get_authorization_url( - domain=self.customer_domain, + organization_id=self.organization_id, provider=self.provider, redirect_uri=self.redirect_uri, - state=self.state, + state=self.authorization_state, ) parsed_url = urlparse(authorization_url) assert dict(parse_qsl(parsed_url.query)) == { - "domain": self.customer_domain, - "provider": str(self.provider.value), + "organization": self.organization_id, + "provider": self.provider, "client_id": workos.client_id, "redirect_uri": self.redirect_uri, "response_type": RESPONSE_TYPE_CODE, - "state": self.state, + "state": self.authorization_state, } - def test_get_profile_and_token_returns_expected_workosprofile_object( - self, setup_with_client_id, mock_profile, mock_request_method + +class TestSSO(SSOFixtures): + provider: SsoProviderType + + @pytest.fixture(autouse=True) + def setup(self, set_api_key_and_client_id): + self.http_client = SyncHTTPClient( + base_url="https://api.workos.test", version="test" + ) + self.sso = SSO(http_client=self.http_client) + self.provider = "GoogleOAuth" + self.customer_domain = "workos.com" + self.login_hint = "foo@workos.com" + self.redirect_uri = "https://localhost/auth/callback" + self.state = json.dumps({"things": "with_stuff"}) + self.connection_id = "connection_123" + self.organization_id = "organization_123" + self.setup_completed = True + + def test_get_profile_and_token_returns_expected_profile_object( + self, mock_profile, mock_http_client_with_response ): response_dict = { "profile": { @@ -417,190 +266,237 @@ def test_get_profile_and_token_returns_expected_workosprofile_object( "access_token": "01DY34ACQTM3B1CSX1YSZ8Z00D", } - mock_request_method("post", response_dict, 200) + mock_http_client_with_response(self.http_client, response_dict, 200) - profile_and_token = self.sso.get_profile_and_token(123) + profile_and_token = self.sso.get_profile_and_token("123") assert profile_and_token.access_token == "01DY34ACQTM3B1CSX1YSZ8Z00D" - assert profile_and_token.profile.to_dict() == mock_profile + assert profile_and_token.profile.dict() == mock_profile - def test_get_profile_and_token_without_first_name_or_last_name_returns_expected_workosprofile_object( - self, setup_with_client_id, mock_magic_link_profile, mock_request_method + def test_get_profile_and_token_without_first_name_or_last_name_returns_expected_profile_object( + self, mock_magic_link_profile, mock_http_client_with_response ): response_dict = { - "profile": { - "object": "profile", - "id": mock_magic_link_profile["id"], - "email": mock_magic_link_profile["email"], - "organization_id": mock_magic_link_profile["organization_id"], - "connection_id": mock_magic_link_profile["connection_id"], - "connection_type": mock_magic_link_profile["connection_type"], - "idp_id": "", - "raw_attributes": {}, - }, + "profile": mock_magic_link_profile, "access_token": "01DY34ACQTM3B1CSX1YSZ8Z00D", } - mock_request_method("post", response_dict, 200) + mock_http_client_with_response(self.http_client, response_dict, 200) - profile_and_token = self.sso.get_profile_and_token(123) + profile_and_token = self.sso.get_profile_and_token("123") assert profile_and_token.access_token == "01DY34ACQTM3B1CSX1YSZ8Z00D" - assert profile_and_token.profile.to_dict() == mock_magic_link_profile + assert profile_and_token.profile.dict() == mock_magic_link_profile - def test_get_profile(self, setup_with_client_id, mock_profile, mock_request_method): - mock_request_method("get", mock_profile, 200) + def test_get_profile(self, mock_profile, mock_http_client_with_response): + mock_http_client_with_response(self.http_client, mock_profile, 200) - profile = self.sso.get_profile(123) + profile = self.sso.get_profile("123") - assert profile.to_dict() == mock_profile + assert profile.dict() == mock_profile - def test_get_connection( - self, setup_with_client_id, mock_connection, mock_request_method - ): - mock_request_method("get", mock_connection, 200) + def test_get_connection(self, mock_connection, mock_http_client_with_response): + mock_http_client_with_response(self.http_client, mock_connection, 200) - connection = self.sso.get_connection(connection="connection_id") + connection = self.sso.get_connection(connection_id="connection_id") - assert connection == mock_connection + assert connection.dict() == mock_connection - def test_list_connections( - self, setup_with_client_id, mock_connections, mock_request_method - ): - mock_request_method("get", mock_connections, 200) + def test_list_connections(self, mock_connections, mock_http_client_with_response): + mock_http_client_with_response(self.http_client, mock_connections, 200) - connections_response = self.sso.list_connections() + connections = self.sso.list_connections() - assert connections_response["data"] == mock_connections["data"] + assert list_data_to_dicts(connections.data) == mock_connections["data"] - def test_list_connections_with_connection_type_as_invalid_string( - self, setup_with_client_id, mock_connections, mock_request_method + def test_list_connections_with_connection_type( + self, mock_connections, capture_and_mock_http_client_request ): - mock_request_method("get", mock_connections, 200) + _, request_kwargs = capture_and_mock_http_client_request( + http_client=self.http_client, + response_dict=mock_connections, + status_code=200, + ) - with pytest.raises( - ValueError, match="'connection_type' must be a member of ConnectionType" - ): - self.sso.list_connections(connection_type="UnknownSAML") + self.sso.list_connections(connection_type="GenericSAML") - def test_list_connections_with_connection_type_as_string( - self, setup_with_client_id, mock_connections, capture_and_mock_request - ): - request_args, request_kwargs = capture_and_mock_request( - "get", mock_connections, 200 + assert request_kwargs["params"] == { + "connection_type": "GenericSAML", + "limit": 10, + "order": "desc", + } + + def test_delete_connection(self, mock_http_client_with_response): + mock_http_client_with_response( + self.http_client, + status_code=204, + headers={"content-type": "text/plain; charset=utf-8"}, ) - connections_response = self.sso.list_connections(connection_type="GenericSAML") + response = self.sso.delete_connection(connection_id="connection_id") - request_params = request_kwargs["params"] - assert request_params["connection_type"] == "GenericSAML" + assert response is None - def test_list_connections_with_connection_type_as_enum( - self, setup_with_client_id, mock_connections, capture_and_mock_request + def test_list_connections_auto_pagination( + self, + mock_connections_multiple_data_pages, + mock_pagination_request_for_http_client, ): - request_args, request_kwargs = capture_and_mock_request( - "get", mock_connections, 200 + mock_pagination_request_for_http_client( + http_client=self.http_client, + data_list=mock_connections_multiple_data_pages, + status_code=200, ) - connections_response = self.sso.list_connections( - connection_type=ConnectionType.OktaSAML - ) + connections = self.sso.list_connections() + all_connections = [] - request_params = request_kwargs["params"] - assert request_params["connection_type"] == "OktaSAML" + for connection in connections.auto_paging_iter(): + all_connections.append(connection) - def test_delete_connection(self, setup_with_client_id, mock_raw_request_method): - mock_raw_request_method( - "delete", - "No Content", - 204, - headers={"content-type": "text/plain; charset=utf-8"}, + assert len(list(all_connections)) == len(mock_connections_multiple_data_pages) + assert ( + list_data_to_dicts(all_connections) + ) == mock_connections_multiple_data_pages + + +@pytest.mark.asyncio +class TestAsyncSSO(SSOFixtures): + provider: SsoProviderType + + @pytest.fixture(autouse=True) + def setup(self, set_api_key_and_client_id): + self.http_client = AsyncHTTPClient( + base_url="https://api.workos.test", version="test" ) + self.sso = AsyncSSO(http_client=self.http_client) + self.provider = "GoogleOAuth" + self.customer_domain = "workos.com" + self.login_hint = "foo@workos.com" + self.redirect_uri = "https://localhost/auth/callback" + self.state = json.dumps({"things": "with_stuff"}) + self.connection_id = "connection_123" + self.organization_id = "organization_123" + self.setup_completed = True + + async def test_get_profile_and_token_returns_expected_profile_object( + self, mock_profile, mock_http_client_with_response + ): + response_dict = { + "profile": { + "object": "profile", + "id": mock_profile["id"], + "email": mock_profile["email"], + "first_name": mock_profile["first_name"], + "groups": mock_profile["groups"], + "organization_id": mock_profile["organization_id"], + "connection_id": mock_profile["connection_id"], + "connection_type": mock_profile["connection_type"], + "last_name": mock_profile["last_name"], + "idp_id": mock_profile["idp_id"], + "raw_attributes": { + "email": mock_profile["raw_attributes"]["email"], + "first_name": mock_profile["raw_attributes"]["first_name"], + "last_name": mock_profile["raw_attributes"]["last_name"], + "groups": mock_profile["raw_attributes"]["groups"], + }, + }, + "access_token": "01DY34ACQTM3B1CSX1YSZ8Z00D", + } - response = self.sso.delete_connection(connection="connection_id") + mock_http_client_with_response(self.http_client, response_dict, 200) - assert response is None + profile_and_token = await self.sso.get_profile_and_token("123") - def test_list_connections_auto_pagination( - self, - mock_connections_with_default_limit, - mock_connections_pagination_response, - mock_connections, - mock_request_method, - setup_with_client_id, + assert profile_and_token.access_token == "01DY34ACQTM3B1CSX1YSZ8Z00D" + assert profile_and_token.profile.dict() == mock_profile + + async def test_get_profile_and_token_without_first_name_or_last_name_returns_expected_profile_object( + self, mock_magic_link_profile, mock_http_client_with_response ): - mock_request_method("get", mock_connections_pagination_response, 200) - connections = mock_connections_with_default_limit + response_dict = { + "profile": mock_magic_link_profile, + "access_token": "01DY34ACQTM3B1CSX1YSZ8Z00D", + } - all_connections = SSO.construct_from_response(connections).auto_paging_iter() + mock_http_client_with_response(self.http_client, response_dict, 200) - assert len(*list(all_connections)) == len(mock_connections["data"]) + profile_and_token = await self.sso.get_profile_and_token("123") - def test_list_connections_auto_pagination_v2( - self, - mock_connections_with_default_limit_v2, - mock_connections_pagination_response, - mock_connections, - mock_request_method, - setup_with_client_id, + assert profile_and_token.access_token == "01DY34ACQTM3B1CSX1YSZ8Z00D" + assert profile_and_token.profile.dict() == mock_magic_link_profile + + async def test_get_profile(self, mock_profile, mock_http_client_with_response): + mock_http_client_with_response(self.http_client, mock_profile, 200) + + profile = await self.sso.get_profile("123") + + assert profile.dict() == mock_profile + + async def test_get_connection( + self, mock_connection, mock_http_client_with_response ): - connections = mock_connections_with_default_limit_v2 + mock_http_client_with_response(self.http_client, mock_connection, 200) - mock_request_method("get", mock_connections_pagination_response, 200) - all_connections = connections.auto_paging_iter() + connection = await self.sso.get_connection(connection_id="connection_id") - number_of_connections = len(*list(all_connections)) - assert number_of_connections == len(mock_connections["data"]) + assert connection.dict() == mock_connection - def test_list_connections_honors_limit( - self, - mock_connections_with_limit, - mock_connections_pagination_response, - mock_request_method, - setup_with_client_id, + async def test_list_connections( + self, mock_connections, mock_http_client_with_response ): - connections = mock_connections_with_limit - mock_request_method("get", mock_connections_pagination_response, 200) - all_connections = SSO.construct_from_response(connections).auto_paging_iter() + mock_http_client_with_response(self.http_client, mock_connections, 200) - assert len(*list(all_connections)) == len(mock_connections_with_limit["data"]) + connections = await self.sso.list_connections() - def test_list_connections_honors_limit_v2( - self, - mock_connections_with_limit_v2, - mock_connections_pagination_response, - mock_request_method, - setup_with_client_id, + assert list_data_to_dicts(connections.data) == mock_connections["data"] + + async def test_list_connections_with_connection_type( + self, mock_connections, capture_and_mock_http_client_request ): - connections = mock_connections_with_limit_v2 - mock_request_method("get", mock_connections_pagination_response, 200) - all_connections = connections.auto_paging_iter() - dict_mock_connections_with_limit = mock_connections_with_limit_v2.to_dict() + _, request_kwargs = capture_and_mock_http_client_request( + http_client=self.http_client, + response_dict=mock_connections, + status_code=200, + ) - assert len(*list(all_connections)) == len( - dict_mock_connections_with_limit["data"] + await self.sso.list_connections(connection_type="GenericSAML") + + assert request_kwargs["params"] == { + "connection_type": "GenericSAML", + "limit": 10, + "order": "desc", + } + + async def test_delete_connection(self, mock_http_client_with_response): + mock_http_client_with_response( + self.http_client, + status_code=204, + headers={"content-type": "text/plain; charset=utf-8"}, ) - def test_list_connections_returns_metadata( - self, - mock_connections, - mock_request_method, - setup_with_client_id, - ): - mock_request_method("get", mock_connections, 200) - connections = self.sso.list_connections(domain="planet-express.com") + response = await self.sso.delete_connection(connection_id="connection_id") - assert connections["metadata"]["params"]["domain"] == "planet-express.com" + assert response is None - def test_list_connections_returns_metadata_v2( + async def test_list_connections_auto_pagination( self, - mock_connections, - mock_request_method, - setup_with_client_id, + mock_connections_multiple_data_pages, + mock_pagination_request_for_http_client, ): - mock_request_method("get", mock_connections, 200) + mock_pagination_request_for_http_client( + http_client=self.http_client, + data_list=mock_connections_multiple_data_pages, + status_code=200, + ) + + connections = await self.sso.list_connections() + all_connections = [] - connections = self.sso.list_connections_v2(domain="planet-express.com") - dict_connections = connections.to_dict() + async for connection in connections.auto_paging_iter(): + all_connections.append(connection) - assert dict_connections["metadata"]["params"]["domain"] == "planet-express.com" + assert len(list(all_connections)) == len(mock_connections_multiple_data_pages) + assert ( + list_data_to_dicts(all_connections) + ) == mock_connections_multiple_data_pages diff --git a/tests/utils/fixtures/mock_connection.py b/tests/utils/fixtures/mock_connection.py index d15101bb..1bf7352a 100644 --- a/tests/utils/fixtures/mock_connection.py +++ b/tests/utils/fixtures/mock_connection.py @@ -4,16 +4,21 @@ class MockConnection(WorkOSBaseResource): def __init__(self, id): - self.object = "organization" + self.object = "connection" self.id = id self.organization_id = "org_id_" + id - self.connection_type = "Okta" + self.connection_type = "OktaSAML" self.name = "Foo Corporation" - self.state = None - self.created_at = datetime.datetime.now() - self.updated_at = datetime.datetime.now() - self.status = None - self.domains = ["domain1.com"] + self.state = "active" + self.created_at = datetime.datetime.now().isoformat() + self.updated_at = datetime.datetime.now().isoformat() + self.domains = [ + { + "id": "connection_domain_abc123", + "object": "connection_domain", + "domain": "domain1.com", + } + ] OBJECT_FIELDS = [ "object", @@ -24,6 +29,5 @@ def __init__(self, id): "state", "created_at", "updated_at", - "status", "domains", ] diff --git a/workos/async_client.py b/workos/async_client.py index c238c387..1b1d1cd7 100644 --- a/workos/async_client.py +++ b/workos/async_client.py @@ -1,6 +1,7 @@ from workos._base_client import BaseClient from workos.directory_sync import AsyncDirectorySync from workos.events import AsyncEvents +from workos.sso import AsyncSSO from workos.utils.http_client import AsyncHTTPClient @@ -16,7 +17,9 @@ def __init__(self, base_url: str, version: str, timeout: int): @property def sso(self): - raise NotImplementedError("SSO APIs are not yet supported in the async client.") + if not getattr(self, "_sso", None): + self._sso = AsyncSSO(self._http_client) + return self._sso @property def audit_logs(self): diff --git a/workos/client.py b/workos/client.py index 82803ed7..0fa77d5c 100644 --- a/workos/client.py +++ b/workos/client.py @@ -25,7 +25,7 @@ def __init__(self, base_url: str, version: str, timeout: int): @property def sso(self): if not getattr(self, "_sso", None): - self._sso = SSO() + self._sso = SSO(self._http_client) return self._sso @property diff --git a/workos/directory_sync.py b/workos/directory_sync.py index 678cf422..4ff4fc36 100644 --- a/workos/directory_sync.py +++ b/workos/directory_sync.py @@ -3,7 +3,11 @@ 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 import REQUEST_METHOD_DELETE, REQUEST_METHOD_GET +from workos.utils.request import ( + DEFAULT_LIST_RESPONSE_LIMIT, + REQUEST_METHOD_DELETE, + REQUEST_METHOD_GET, +) from workos.utils.validation import DIRECTORY_SYNC_MODULE, validate_settings from workos.resources.directory_sync import ( DirectoryGroup, @@ -19,9 +23,6 @@ ) -RESPONSE_LIMIT = 10 - - class DirectoryListFilters(ListArgs, total=False): search: Optional[str] organization_id: Optional[str] @@ -46,7 +47,7 @@ def list_users( self, directory: Optional[str] = None, group: Optional[str] = None, - limit: int = RESPONSE_LIMIT, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", @@ -56,7 +57,7 @@ def list_groups( self, directory: Optional[str] = None, user: Optional[str] = None, - limit: int = RESPONSE_LIMIT, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", @@ -72,7 +73,7 @@ def list_directories( self, domain: Optional[str] = None, search: Optional[str] = None, - limit: int = RESPONSE_LIMIT, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, before: Optional[str] = None, after: Optional[str] = None, organization: Optional[str] = None, @@ -95,7 +96,7 @@ def list_users( self, directory: Optional[str] = None, group: Optional[str] = None, - limit: Optional[int] = RESPONSE_LIMIT, + limit: Optional[int] = DEFAULT_LIST_RESPONSE_LIMIT, before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", @@ -117,7 +118,7 @@ def list_users( """ list_params: DirectoryUserListFilters = { - "limit": limit if limit is not None else RESPONSE_LIMIT, + "limit": limit if limit is not None else DEFAULT_LIST_RESPONSE_LIMIT, "before": before, "after": after, "order": order, @@ -145,7 +146,7 @@ def list_groups( self, directory: Optional[str] = None, user: Optional[str] = None, - limit: int = RESPONSE_LIMIT, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", @@ -246,7 +247,7 @@ def list_directories( self, domain: Optional[str] = None, search: Optional[str] = None, - limit: int = RESPONSE_LIMIT, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, before: Optional[str] = None, after: Optional[str] = None, organization: Optional[str] = None, @@ -318,7 +319,7 @@ async def list_users( self, directory: Optional[str] = None, group: Optional[str] = None, - limit: int = RESPONSE_LIMIT, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", @@ -368,7 +369,7 @@ async def list_groups( self, directory: Optional[str] = None, user: Optional[str] = None, - limit: int = RESPONSE_LIMIT, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", @@ -468,7 +469,7 @@ async def list_directories( self, domain: Optional[str] = None, search: Optional[str] = None, - limit: int = RESPONSE_LIMIT, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, before: Optional[str] = None, after: Optional[str] = None, organization: Optional[str] = None, diff --git a/workos/events.py b/workos/events.py index 809c7315..80e838f5 100644 --- a/workos/events.py +++ b/workos/events.py @@ -2,7 +2,7 @@ import workos from workos.typing.sync_or_async import SyncOrAsync -from workos.utils.request import REQUEST_METHOD_GET +from workos.utils.request import DEFAULT_LIST_RESPONSE_LIMIT, REQUEST_METHOD_GET from workos.resources.events import Event, EventType from workos.utils.http_client import AsyncHTTPClient, SyncHTTPClient from workos.utils.validation import EVENTS_MODULE, validate_settings @@ -12,8 +12,6 @@ WorkOsListResource, ) -RESPONSE_LIMIT = 10 - class EventsListFilters(ListArgs, total=False): events: List[EventType] @@ -29,7 +27,7 @@ class EventsModule(Protocol): def list_events( self, events: List[EventType], - limit: int = RESPONSE_LIMIT, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, organization_id: Optional[str] = None, after: Optional[str] = None, range_start: Optional[str] = None, @@ -49,7 +47,7 @@ def __init__(self, http_client: SyncHTTPClient): def list_events( self, events: List[EventType], - limit: int = RESPONSE_LIMIT, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, organization: Optional[str] = None, after: Optional[str] = None, range_start: Optional[str] = None, @@ -103,7 +101,7 @@ def __init__(self, http_client: AsyncHTTPClient): async def list_events( self, events: List[EventType], - limit: int = RESPONSE_LIMIT, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, organization_id: Optional[str] = None, after: Optional[str] = None, range_start: Optional[str] = None, diff --git a/workos/organizations.py b/workos/organizations.py index 34d4d0dc..a43baf30 100644 --- a/workos/organizations.py +++ b/workos/organizations.py @@ -2,6 +2,7 @@ import workos from workos.utils.pagination_order import PaginationOrder from workos.utils.request import ( + DEFAULT_LIST_RESPONSE_LIMIT, RequestHelper, REQUEST_METHOD_DELETE, REQUEST_METHOD_GET, @@ -16,7 +17,6 @@ from workos.resources.list import ListPage, WorkOsListResource, ListArgs ORGANIZATIONS_PATH = "organizations" -RESPONSE_LIMIT = 10 class OrganizationListFilters(ListArgs, total=False): @@ -27,7 +27,7 @@ class OrganizationsModule(Protocol): def list_organizations( self, domains: Optional[List[str]] = None, - limit: int = RESPONSE_LIMIT, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", @@ -68,7 +68,7 @@ def request_helper(self): def list_organizations( self, domains: Optional[List[str]] = None, - limit: int = RESPONSE_LIMIT, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", diff --git a/workos/resources/list.py b/workos/resources/list.py index e714f3f2..560eb6d5 100644 --- a/workos/resources/list.py +++ b/workos/resources/list.py @@ -19,6 +19,7 @@ from workos.resources.events import Event from workos.resources.organizations import Organization from pydantic import BaseModel, Field +from workos.resources.sso import Connection from workos.resources.workos_model import WorkOSModel @@ -116,11 +117,12 @@ def auto_paging_iter(self): ListableResource = TypeVar( # add all possible generics of List Resource "ListableResource", - Organization, + Connection, Directory, DirectoryGroup, DirectoryUser, Event, + Organization, ) diff --git a/workos/resources/sso.py b/workos/resources/sso.py index c9ec223e..1d9216d3 100644 --- a/workos/resources/sso.py +++ b/workos/resources/sso.py @@ -1,87 +1,61 @@ -from workos.resources.base import WorkOSBaseResource +from typing import List, Literal, Union +from workos.resources.workos_model import WorkOSModel +from workos.typing.literals import LiteralOrUntyped +from workos.utils.connection_types import ConnectionType -class WorkOSProfile(WorkOSBaseResource): - """Representation of a User Profile as returned by WorkOS through the SSO feature. - Attributes: - OBJECT_FIELDS (list): List of fields a WorkOSProfile is comprised of. - """ +class Profile(WorkOSModel): + """Representation of a User Profile as returned by WorkOS through the SSO feature.""" - OBJECT_FIELDS = [ - "id", - "email", - "first_name", - "last_name", - "groups", - "organization_id", - "connection_id", - "connection_type", - "idp_id", - "raw_attributes", - ] + object: Literal["profile"] + id: str + connection_id: str + connection_type: LiteralOrUntyped[ConnectionType] + organization_id: Union[str, None] + email: str + first_name: Union[str, None] + last_name: Union[str, None] + idp_id: str + groups: Union[List[str], None] + raw_attributes: dict -class WorkOSProfileAndToken(WorkOSBaseResource): - """Representation of a User Profile and Access Token as returned by WorkOS through the SSO feature. +class ProfileAndToken(WorkOSModel): + """Representation of a User Profile and Access Token as returned by WorkOS through the SSO feature.""" - Attributes: - OBJECT_FIELDS (list): List of fields a WorkOSProfileAndToken is comprised of. - """ + access_token: str + profile: Profile - OBJECT_FIELDS = [ - "access_token", - ] - @classmethod - def construct_from_response(cls, response): - profile_and_token = super(WorkOSProfileAndToken, cls).construct_from_response( - response - ) +ConnectionState = Literal[ + "active", "deleting", "inactive", "requires_type", "validating" +] - profile_and_token.profile = WorkOSProfile.construct_from_response( - response["profile"] - ) - return profile_and_token +class ConnectionDomain(WorkOSModel): + object: Literal["connection_domain"] + id: str + domain: str - def to_dict(self): - profile_and_token_dict = super(WorkOSProfileAndToken, self).to_dict() - profile_dict = self.profile.to_dict() - profile_and_token_dict["profile"] = profile_dict +class Connection(WorkOSModel): + """Representation of a Connection Response as returned by WorkOS through the SSO feature.""" - return profile_and_token_dict + object: Literal["connection"] + id: str + organization_id: str + connection_type: LiteralOrUntyped[ConnectionType] + name: str + state: LiteralOrUntyped[ConnectionState] + created_at: str + updated_at: str + domains: List[ConnectionDomain] -class WorkOSConnection(WorkOSBaseResource): - """Representation of a Connection Response as returned by WorkOS through the SSO feature. - Attributes: - OBJECT_FIELDS (list): List of fields a WorkOSConnection is comprised of. - """ - - OBJECT_FIELDS = [ - "object", - "id", - "organization_id", - "connection_type", - "name", - "state", - "created_at", - "updated_at", - "status", - "domains", - ] - - @classmethod - def construct_from_response(cls, response): - connection_response = super(WorkOSConnection, cls).construct_from_response( - response - ) - - return connection_response - - def to_dict(self): - connection_response_dict = super(WorkOSConnection, self).to_dict() - - return connection_response_dict +SsoProviderType = Literal[ + "AppleOAuth", + "GitHubOAuth", + "GoogleOAuth", + "MicrosoftOAuth", +] diff --git a/workos/sso.py b/workos/sso.py index 08b2d9f4..293b69a5 100644 --- a/workos/sso.py +++ b/workos/sso.py @@ -1,25 +1,32 @@ -from typing import Protocol -from warnings import warn +from typing import Optional, Protocol, Union -from requests import Request import workos -from workos.utils.pagination_order import Order +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.resources.sso import ( - WorkOSProfile, - WorkOSProfileAndToken, - WorkOSConnection, + Connection, + Profile, + ProfileAndToken, + SsoProviderType, ) from workos.utils.connection_types import ConnectionType -from workos.utils.sso_provider_types import SsoProviderType from workos.utils.request import ( - RequestHelper, + DEFAULT_LIST_RESPONSE_LIMIT, RESPONSE_TYPE_CODE, REQUEST_METHOD_DELETE, REQUEST_METHOD_GET, REQUEST_METHOD_POST, + RequestHelper, ) from workos.utils.validation import SSO_MODULE, validate_settings -from workos.resources.list import WorkOSListResource +from workos.resources.list import ( + AsyncWorkOsListResource, + ListArgs, + ListPage, + SyncOrAsyncListResource, + WorkOsListResource, +) AUTHORIZATION_PATH = "sso/authorize" TOKEN_PATH = "sso/token" @@ -27,90 +34,38 @@ OAUTH_GRANT_TYPE = "authorization_code" -RESPONSE_LIMIT = 10 - - -class SSOModule(Protocol): - def get_authorization_url( - self, - domain=None, - domain_hint=None, - login_hint=None, - redirect_uri=None, - state=None, - provider=None, - connection=None, - organization=None, - ) -> str: ... - - def get_profile(self, accessToken: str) -> WorkOSProfile: ... - - def get_profile_and_token(self, code: str) -> WorkOSProfileAndToken: ... - - def get_connection(self, connection: str) -> dict: ... - def list_connections( - self, - connection_type=None, - domain=None, - organization_id=None, - limit=None, - before=None, - after=None, - order=None, - ) -> dict: ... - - def list_connections_v2( - self, - connection_type=None, - domain=None, - organization_id=None, - limit=None, - before=None, - after=None, - order=None, - ) -> dict: ... +class ConnectionsListFilters(ListArgs, total=False): + connection_type: Optional[ConnectionType] + domain: Optional[str] + organization_id: Optional[str] - def delete_connection(self, connection: str) -> None: ... - -class SSO(SSOModule, WorkOSListResource): - """Offers methods to assist in authenticating through the WorkOS SSO service.""" - - @validate_settings(SSO_MODULE) - def __init__(self): - pass - - @property - def request_helper(self): - if not getattr(self, "_request_helper", None): - self._request_helper = RequestHelper() - return self._request_helper +class SSOModule(Protocol): + _http_client: Union[SyncHTTPClient, AsyncHTTPClient] def get_authorization_url( self, - domain=None, - domain_hint=None, - login_hint=None, - redirect_uri=None, - state=None, - provider=None, - connection=None, - organization=None, - ): + redirect_uri: str, + domain_hint: Optional[str] = None, + login_hint: Optional[str] = None, + state: Optional[str] = None, + provider: Optional[SsoProviderType] = None, + connection_id: Optional[str] = None, + organization_id: Optional[str] = None, + ) -> str: """Generate an OAuth 2.0 authorization URL. The URL generated will redirect a User to the Identity Provider configured through WorkOS. Kwargs: - domain (str) - The domain a user is associated with, as configured on WorkOS redirect_uri (str) - A valid redirect URI, as specified on WorkOS state (str) - An encoded string passed to WorkOS that'd be preserved through the authentication workflow, passed back as a query parameter - provider (SsoProviderType) - Authentication service provider descriptor - connection (string) - Unique identifier for a WorkOS Connection - organization (string) - Unique identifier for a WorkOS Organization + provider (SSOProviderType) - Authentication service provider descriptor + connection_id (string) - Unique identifier for a WorkOS Connection + organization_id (string) - Unique identifier for a WorkOS Organization Returns: str: URL to redirect a User to to begin the OAuth workflow with WorkOS @@ -121,50 +76,58 @@ def get_authorization_url( "response_type": RESPONSE_TYPE_CODE, } - if ( - domain is None - and provider is None - and connection is None - and organization is None - ): + if connection_id is None and organization_id is None and provider is None: raise ValueError( - "Incomplete arguments. Need to specify either a 'connection', 'organization', 'domain', or 'provider'" + "Incomplete arguments. Need to specify either a 'connection', 'organization', or 'provider'" ) if provider is not None: - if not isinstance(provider, SsoProviderType): - raise ValueError("'provider' must be of type SsoProviderType") - - params["provider"] = provider.value - if domain is not None: - warn( - "The 'domain' parameter for 'get_authorization_url' is deprecated. Please use 'organization' instead.", - DeprecationWarning, - ) - params["domain"] = domain + params["provider"] = provider if domain_hint is not None: params["domain_hint"] = domain_hint if login_hint is not None: params["login_hint"] = login_hint - if connection is not None: - params["connection"] = connection - if organization is not None: - params["organization"] = organization + if connection_id is not None: + params["connection"] = connection_id + if organization_id is not None: + params["organization"] = organization_id if state is not None: params["state"] = state - if redirect_uri is None: - raise ValueError("Incomplete arguments. Need to specify a 'redirect_uri'.") + return RequestHelper.build_url_with_query_params( + self._http_client.base_url, **params + ) - prepared_request = Request( - "GET", - self.request_helper.generate_api_url(AUTHORIZATION_PATH), - params=params, - ).prepare() + def get_profile(self, accessToken: str) -> SyncOrAsync[Profile]: ... + + def get_profile_and_token(self, code: str) -> SyncOrAsync[ProfileAndToken]: ... - return prepared_request.url + def get_connection(self, connection: str) -> SyncOrAsync[Connection]: ... + + def list_connections( + self, + connection_type: Optional[ConnectionType] = None, + domain: Optional[str] = None, + organization_id: Optional[str] = None, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> SyncOrAsyncListResource: ... - def get_profile(self, accessToken): + def delete_connection(self, connection: str) -> SyncOrAsync[None]: ... + + +class SSO(SSOModule): + """Offers methods to assist in authenticating through the WorkOS SSO service.""" + + _http_client: SyncHTTPClient + + @validate_settings(SSO_MODULE) + def __init__(self, http_client: SyncHTTPClient): + self._http_client = http_client + + def get_profile(self, access_token: str) -> Profile: """ Verify that SSO has been completed successfully and retrieve the identity of the user. @@ -172,18 +135,15 @@ def get_profile(self, accessToken): accessToken (str): the token used to authenticate the API call Returns: - WorkOSProfile + Profile """ - - token = accessToken - - response = self.request_helper.request( - PROFILE_PATH, method=REQUEST_METHOD_GET, token=token + response = self._http_client.request( + PROFILE_PATH, method=REQUEST_METHOD_GET, token=access_token ) - return WorkOSProfile.construct_from_response(response) + return Profile.model_validate(response) - def get_profile_and_token(self, code): + def get_profile_and_token(self, code: str) -> ProfileAndToken: """Get the profile of an authenticated User Once authenticated, using the code returned having followed the authorization URL, @@ -193,7 +153,7 @@ def get_profile_and_token(self, code): code (str): Code returned by WorkOS on completion of OAuth 2.0 workflow Returns: - WorkOSProfileAndToken: WorkOSProfileAndToken object representing the User + ProfileAndToken: WorkOSProfileAndToken object representing the User """ params = { "client_id": workos.client_id, @@ -202,13 +162,13 @@ def get_profile_and_token(self, code): "grant_type": OAUTH_GRANT_TYPE, } - response = self.request_helper.request( + response = self._http_client.request( TOKEN_PATH, method=REQUEST_METHOD_POST, params=params ) - return WorkOSProfileAndToken.construct_from_response(response) + return ProfileAndToken.model_validate(response) - def get_connection(self, connection): + def get_connection(self, connection_id: str) -> Connection: """Gets details for a single Connection Args: @@ -217,24 +177,24 @@ def get_connection(self, connection): Returns: dict: Connection response from WorkOS. """ - response = self.request_helper.request( - "connections/{connection}".format(connection=connection), + response = self._http_client.request( + f"connections/{connection_id}", method=REQUEST_METHOD_GET, token=workos.api_key, ) - return WorkOSConnection.construct_from_response(response).to_dict() + return Connection.model_validate(response) def list_connections( self, - connection_type=None, - domain=None, - organization_id=None, - limit=None, - before=None, - after=None, - order=None, - ): + connection_type: Optional[ConnectionType] = None, + domain: Optional[str] = None, + organization_id: Optional[str] = None, + limit: Optional[int] = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> WorkOsListResource[Connection, ConnectionsListFilters]: """Gets details for existing Connections. Args: @@ -248,33 +208,9 @@ def list_connections( Returns: dict: Connections response from WorkOS. """ - warn( - "The 'list_connections' method is deprecated. Please use 'list_connections_v2' instead.", - DeprecationWarning, - ) - - # This method used to accept `connection_type` as a string, so we try - # to convert strings to a `ConnectionType` to support existing callers. - # - # TODO: Remove support for string values of `ConnectionType` in the next - # major version. - if connection_type is not None and isinstance(connection_type, str): - try: - connection_type = ConnectionType[connection_type] - - warn( - "Passing a string value as the 'connection_type' parameter for 'list_connections' is deprecated and will be removed in the next major version. Please pass a 'ConnectionType' instead.", - DeprecationWarning, - ) - except KeyError: - raise ValueError("'connection_type' must be a member of ConnectionType") - - if limit is None: - limit = RESPONSE_LIMIT - default_limit = True params = { - "connection_type": connection_type.value if connection_type else None, + "connection_type": connection_type, "domain": domain, "organization_id": organization_id, "limit": limit, @@ -283,45 +219,109 @@ def list_connections( "order": order or "desc", } - if order is not None: - if isinstance(order, Order): - params["order"] = str(order.value) - - elif order == "asc" or order == "desc": - params["order"] = order - else: - raise ValueError("Parameter order must be of enum type Order") - - response = self.request_helper.request( + response = self._http_client.request( "connections", method=REQUEST_METHOD_GET, params=params, token=workos.api_key, ) - response["metadata"] = { - "params": params, - "method": SSO.list_connections, + return WorkOsListResource( + list_method=self.list_connections, + list_args=params, + **ListPage[Connection](**response).model_dump(), + ) + + def delete_connection(self, connection_id: str) -> None: + """Deletes a single Connection + + Args: + connection (str): Connection unique identifier + """ + self._http_client.request( + f"connections/{connection_id}", + method=REQUEST_METHOD_DELETE, + token=workos.api_key, + ) + + +class AsyncSSO(SSOModule): + """Offers methods to assist in authenticating through the WorkOS SSO service.""" + + _http_client: AsyncHTTPClient + + @validate_settings(SSO_MODULE) + def __init__(self, http_client: AsyncHTTPClient): + self._http_client = http_client + + async def get_profile(self, access_token: str) -> Profile: + """ + Verify that SSO has been completed successfully and retrieve the identity of the user. + + Args: + accessToken (str): the token used to authenticate the API call + + Returns: + Profile + """ + response = await self._http_client.request( + PROFILE_PATH, method=REQUEST_METHOD_GET, token=access_token + ) + + return Profile.model_validate(response) + + async def get_profile_and_token(self, code: str) -> ProfileAndToken: + """Get the profile of an authenticated User + + Once authenticated, using the code returned having followed the authorization URL, + get the WorkOS profile of the User. + + Args: + code (str): Code returned by WorkOS on completion of OAuth 2.0 workflow + + Returns: + ProfileAndToken: WorkOSProfileAndToken object representing the User + """ + params = { + "client_id": workos.client_id, + "client_secret": workos.api_key, + "code": code, + "grant_type": OAUTH_GRANT_TYPE, } - if "default_limit" in locals(): - if "metadata" in response and "params" in response["metadata"]: - response["metadata"]["params"]["default_limit"] = default_limit - else: - response["metadata"] = {"params": {"default_limit": default_limit}} + response = await self._http_client.request( + TOKEN_PATH, method=REQUEST_METHOD_POST, params=params + ) + + return ProfileAndToken.model_validate(response) + + async def get_connection(self, connection_id: str) -> Connection: + """Gets details for a single Connection + + Args: + connection (str): Connection unique identifier + + Returns: + dict: Connection response from WorkOS. + """ + response = await self._http_client.request( + f"connections/{connection_id}", + method=REQUEST_METHOD_GET, + token=workos.api_key, + ) - return response + return Connection.model_validate(response) - def list_connections_v2( + async def list_connections( self, - connection_type=None, - domain=None, - organization_id=None, - limit=None, - before=None, - after=None, - order=None, - ): + connection_type: Optional[ConnectionType] = None, + domain: Optional[str] = None, + organization_id: Optional[str] = None, + limit: Optional[int] = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> AsyncWorkOsListResource[Connection, ConnectionsListFilters]: """Gets details for existing Connections. Args: @@ -336,28 +336,8 @@ def list_connections_v2( dict: Connections response from WorkOS. """ - # This method used to accept `connection_type` as a string, so we try - # to convert strings to a `ConnectionType` to support existing callers. - # - # TODO: Remove support for string values of `ConnectionType` in the next - # major version. - if connection_type is not None and isinstance(connection_type, str): - try: - connection_type = ConnectionType[connection_type] - - warn( - "Passing a string value as the 'connection_type' parameter for 'list_connections' is deprecated and will be removed in the next major version. Please pass a 'ConnectionType' instead.", - DeprecationWarning, - ) - except KeyError: - raise ValueError("'connection_type' must be a member of ConnectionType") - - if limit is None: - limit = RESPONSE_LIMIT - default_limit = True - params = { - "connection_type": connection_type.value if connection_type else None, + "connection_type": connection_type, "domain": domain, "organization_id": organization_id, "limit": limit, @@ -366,43 +346,27 @@ def list_connections_v2( "order": order or "desc", } - if order is not None: - if isinstance(order, Order): - params["order"] = str(order.value) - - elif order == "asc" or order == "desc": - params["order"] = order - else: - raise ValueError("Parameter order must be of enum type Order") - - response = self.request_helper.request( + response = await self._http_client.request( "connections", method=REQUEST_METHOD_GET, params=params, token=workos.api_key, ) - response["metadata"] = { - "params": params, - "method": SSO.list_connections_v2, - } - - if "default_limit" in locals(): - if "metadata" in response and "params" in response["metadata"]: - response["metadata"]["params"]["default_limit"] = default_limit - else: - response["metadata"] = {"params": {"default_limit": default_limit}} - - return self.construct_from_response(response) + return AsyncWorkOsListResource( + list_method=self.list_connections, + list_args=params, + **ListPage[Connection](**response).model_dump(), + ) - def delete_connection(self, connection): + async def delete_connection(self, connection_id: str) -> None: """Deletes a single Connection Args: connection (str): Connection unique identifier """ - return self.request_helper.request( - "connections/{connection}".format(connection=connection), + await self._http_client.request( + f"connections/{connection_id}", method=REQUEST_METHOD_DELETE, token=workos.api_key, ) diff --git a/workos/user_management.py b/workos/user_management.py index 8a664697..36125f7b 100644 --- a/workos/user_management.py +++ b/workos/user_management.py @@ -19,6 +19,7 @@ from workos.utils.pagination_order import Order from workos.utils.um_provider_types import UserManagementProviderType from workos.utils.request import ( + DEFAULT_LIST_RESPONSE_LIMIT, RequestHelper, RESPONSE_TYPE_CODE, REQUEST_METHOD_POST, @@ -56,8 +57,6 @@ PASSWORD_RESET_PATH = "user_management/password_reset" PASSWORD_RESET_DETAIL_PATH = "user_management/password_reset/{0}" -RESPONSE_LIMIT = 10 - class UserManagementModule(Protocol): def get_user(self, user_id: str) -> dict: ... @@ -293,7 +292,7 @@ def list_users( default_limit = None if limit is None: - limit = RESPONSE_LIMIT + limit = DEFAULT_LIST_RESPONSE_LIMIT default_limit = True params = { @@ -504,7 +503,7 @@ def list_organization_memberships( default_limit = None if limit is None: - limit = RESPONSE_LIMIT + limit = DEFAULT_LIST_RESPONSE_LIMIT default_limit = True if statuses is not None: @@ -1444,7 +1443,7 @@ def list_invitations( default_limit = None if limit is None: - limit = RESPONSE_LIMIT + limit = DEFAULT_LIST_RESPONSE_LIMIT default_limit = True params = { diff --git a/workos/utils/_base_http_client.py b/workos/utils/_base_http_client.py index 91d4dccb..13b5637b 100644 --- a/workos/utils/_base_http_client.py +++ b/workos/utils/_base_http_client.py @@ -176,6 +176,16 @@ def _handle_response(self, response: httpx.Response) -> dict: return cast(Dict, response_json) + def build_request_url( + self, + url: str, + method: Optional[str] = REQUEST_METHOD_GET, + params: Optional[Mapping] = None, + ) -> str: + return self._client.build_request( + method=method or REQUEST_METHOD_GET, url=url, params=params + ).url.__str__() + @property def base_url(self) -> str: return self._base_url @@ -207,3 +217,7 @@ def user_agent(self) -> str: @property def timeout(self) -> int: return self._timeout + + @property + def version(self) -> str: + return self._version diff --git a/workos/utils/connection_types.py b/workos/utils/connection_types.py index 32e1ef20..739026c4 100644 --- a/workos/utils/connection_types.py +++ b/workos/utils/connection_types.py @@ -1,48 +1,39 @@ -from enum import Enum +from typing import Literal -class ConnectionType(Enum): - ADFSSAML = "ADFSSAML" - AdpOidc = "AdpOidc" - AppleOAuth = "AppleOAuth" - Auth0SAML = "Auth0SAML" - AzureSAML = "AzureSAML" - CasSAML = "CasSAML" - CloudflareSAML = "CloudflareSAML" - ClassLinkSAML = "ClassLinkSAML" - CyberArkSAML = "CyberArkSAML" - DuoSAML = "DuoSAML" - GenericOIDC = "GenericOIDC" - GenericSAML = "GenericSAML" - GitHubOAuth = "GitHubOAuth" - GoogleOAuth = "GoogleOAuth" - GoogleSAML = "GoogleSAML" - JumpCloudSAML = "JumpCloudSAML" - KeycloakSAML = "KeycloakSAML" - LastPassSAML = "LastPassSAML" - MagicLink = "MagicLink" - MicrosoftOAuth = "MicrosoftOAuth" - MiniOrangeSAML = "MiniOrangeSAML" - NetIqSAML = "NetIqSAML" - OktaSAML = "OktaSAML" - OneLoginSAML = "OneLoginSAML" - OracleSAML = "OracleSAML" - PingFederateSAML = "PingFederateSAML" - PingOneSAML = "PingOneSAML" - RipplingSAML = "RipplingSAML" - SalesforceSAML = "SalesforceSAML" - ShibbolethGenericSAML = "ShibbolethGenericSAML" - ShibbolethSAML = "ShibbolethSAML" - SimpleSamlPhpSAML = "SimpleSamlPhpSAML" - VMwareSAML = "VMwareSAML" - - @classmethod - def providers(cls): - """Returns a generator of all connection types/providers. - This is only needed as a workaround for providers passed - as a string connection type. - - Returns: - generator(list): A lazy list of all connection types - """ - return (connection_type.value for connection_type in ConnectionType) +ConnectionType = Literal[ + "ADFSSAML", + "AdpOidc", + "AppleOAuth", + "Auth0SAML", + "AzureSAML", + "CasSAML", + "CloudflareSAML", + "ClassLinkSAML", + "CyberArkSAML", + "DuoSAML", + "GenericOIDC", + "GenericSAML", + "GitHubOAuth", + "GoogleOAuth", + "GoogleSAML", + "JumpCloudSAML", + "KeycloakSAML", + "LastPassSAML", + "LoginGovOidc", + "MagicLink", + "MicrosoftOAuth", + "MiniOrangeSAML", + "NetIqSAML", + "OktaSAML", + "OneLoginSAML", + "OracleSAML", + "PingFederateSAML", + "PingOneSAML", + "RipplingSAML", + "SalesforceSAML", + "ShibbolethGenericSAML", + "ShibbolethSAML", + "SimpleSamlPhpSAML", + "VMwareSAML", +] diff --git a/workos/utils/request.py b/workos/utils/request.py index e54aeafd..09173f18 100644 --- a/workos/utils/request.py +++ b/workos/utils/request.py @@ -21,8 +21,8 @@ ), } +DEFAULT_LIST_RESPONSE_LIMIT = 10 RESPONSE_TYPE_CODE = "code" - REQUEST_METHOD_DELETE = "delete" REQUEST_METHOD_GET = "get" REQUEST_METHOD_POST = "post" @@ -52,6 +52,10 @@ def build_parameterized_url(self, url, **params): escaped_params = {k: urllib.parse.quote(str(v)) for k, v in params.items()} return url.format(**escaped_params) + @classmethod + def build_url_with_query_params(cls, url, **params): + return url.format("?" + urllib.parse.urlencode(params)) + def request( self, path, diff --git a/workos/utils/sso_provider_types.py b/workos/utils/sso_provider_types.py deleted file mode 100644 index 1be0120d..00000000 --- a/workos/utils/sso_provider_types.py +++ /dev/null @@ -1,8 +0,0 @@ -from enum import Enum - - -class SsoProviderType(Enum): - AppleOAuth = "AppleOAuth" - GitHubOAuth = "GitHubOAuth" - GoogleOAuth = "GoogleOAuth" - MicrosoftOAuth = "MicrosoftOAuth" From 47cd107820a1093887164b04b79e25bb1a2da9cb Mon Sep 17 00:00:00 2001 From: pantera Date: Thu, 25 Jul 2024 14:54:51 -0700 Subject: [PATCH 08/18] Type all dsync events (#292) * Add types for directory group events * Add directory user events * Under refactor of separate type files, we can do that later across all resources if we want * Add group membership events * Fix types for user events, they do not contain groups * Reformat * Reformat... again * mypy fixes --- workos/directory_sync.py | 20 ++-- workos/resources/directory_sync.py | 35 +------ workos/resources/events.py | 94 ++++++++++++++++++- workos/resources/list.py | 9 +- workos/types/directory_sync/directory_user.py | 38 ++++++++ .../directory_group_membership_payload.py | 9 ++ ...irectory_group_with_previous_attributes.py | 6 ++ ...directory_user_with_previous_attributes.py | 6 ++ workos/types/events/previous_attributes.py | 3 + 9 files changed, 172 insertions(+), 48 deletions(-) create mode 100644 workos/types/directory_sync/directory_user.py create mode 100644 workos/types/events/directory_group_membership_payload.py create mode 100644 workos/types/events/directory_group_with_previous_attributes.py create mode 100644 workos/types/events/directory_user_with_previous_attributes.py create mode 100644 workos/types/events/previous_attributes.py diff --git a/workos/directory_sync.py b/workos/directory_sync.py index 4ff4fc36..6416371f 100644 --- a/workos/directory_sync.py +++ b/workos/directory_sync.py @@ -12,7 +12,7 @@ from workos.resources.directory_sync import ( DirectoryGroup, Directory, - DirectoryUser, + DirectoryUserWithGroups, ) from workos.resources.list import ( ListArgs, @@ -63,7 +63,7 @@ def list_groups( order: PaginationOrder = "desc", ) -> SyncOrAsyncListResource: ... - def get_user(self, user: str) -> SyncOrAsync[DirectoryUser]: ... + def get_user(self, user: str) -> SyncOrAsync[DirectoryUserWithGroups]: ... def get_group(self, group: str) -> SyncOrAsync[DirectoryGroup]: ... @@ -100,7 +100,7 @@ def list_users( before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", - ) -> WorkOsListResource[DirectoryUser, DirectoryUserListFilters]: + ) -> WorkOsListResource[DirectoryUserWithGroups, DirectoryUserListFilters]: """Gets a list of provisioned Users for a Directory. Note, either 'directory' or 'group' must be provided. @@ -139,7 +139,7 @@ def list_users( return WorkOsListResource( list_method=self.list_users, list_args=list_params, - **ListPage[DirectoryUser](**response).model_dump(), + **ListPage[DirectoryUserWithGroups](**response).model_dump(), ) def list_groups( @@ -191,7 +191,7 @@ def list_groups( **ListPage[DirectoryGroup](**response).model_dump(), ) - def get_user(self, user: str) -> DirectoryUser: + def get_user(self, user: str) -> DirectoryUserWithGroups: """Gets details for a single provisioned Directory User. Args: @@ -206,7 +206,7 @@ def get_user(self, user: str) -> DirectoryUser: token=workos.api_key, ) - return DirectoryUser.model_validate(response) + return DirectoryUserWithGroups.model_validate(response) def get_group(self, group: str) -> DirectoryGroup: """Gets details for a single provisioned Directory Group. @@ -323,7 +323,7 @@ async def list_users( before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", - ) -> AsyncWorkOsListResource[DirectoryUser, DirectoryUserListFilters]: + ) -> AsyncWorkOsListResource[DirectoryUserWithGroups, DirectoryUserListFilters]: """Gets a list of provisioned Users for a Directory. Note, either 'directory' or 'group' must be provided. @@ -362,7 +362,7 @@ async def list_users( return AsyncWorkOsListResource( list_method=self.list_users, list_args=list_params, - **ListPage[DirectoryUser](**response).model_dump(), + **ListPage[DirectoryUserWithGroups](**response).model_dump(), ) async def list_groups( @@ -413,7 +413,7 @@ async def list_groups( **ListPage[DirectoryGroup](**response).model_dump(), ) - async def get_user(self, user: str) -> DirectoryUser: + async def get_user(self, user: str) -> DirectoryUserWithGroups: """Gets details for a single provisioned Directory User. Args: @@ -428,7 +428,7 @@ async def get_user(self, user: str) -> DirectoryUser: token=workos.api_key, ) - return DirectoryUser.model_validate(response) + return DirectoryUserWithGroups.model_validate(response) async def get_group(self, group: str) -> DirectoryGroup: """Gets details for a single provisioned Directory Group. diff --git a/workos/resources/directory_sync.py b/workos/resources/directory_sync.py index b053a866..7ca6b63c 100644 --- a/workos/resources/directory_sync.py +++ b/workos/resources/directory_sync.py @@ -1,6 +1,7 @@ from typing import List, Optional, Literal from workos.resources.workos_model import WorkOSModel from workos.types.directory_sync.directory_state import DirectoryState +from workos.types.directory_sync.directory_user import DirectoryUser from workos.typing.literals import LiteralOrUntyped DirectoryType = Literal[ @@ -62,43 +63,11 @@ class DirectoryGroup(WorkOSModel): updated_at: str -class DirectoryUserEmail(WorkOSModel): - type: Optional[str] = None - value: Optional[str] = None - primary: Optional[bool] = None - - -class Role(WorkOSModel): - slug: str - - -DirectoryUserState = Literal["active", "inactive"] - - -class DirectoryUser(WorkOSModel): +class DirectoryUserWithGroups(DirectoryUser): """Representation of a Directory User as returned by WorkOS through the Directory Sync feature. Attributes: OBJECT_FIELDS (list): List of fields a DirectoryUser is comprised of. """ - id: str - object: Literal["directory_user"] - idp_id: str - directory_id: str - organization_id: str - first_name: Optional[str] = None - last_name: Optional[str] = None - job_title: Optional[str] = None - emails: List[DirectoryUserEmail] - username: Optional[str] = None groups: List[DirectoryGroup] - state: DirectoryUserState - custom_attributes: dict - raw_attributes: dict - created_at: str - updated_at: str - role: Optional[Role] = None - - def primary_email(self): - return next((email for email in self.emails if email.primary), None) diff --git a/workos/resources/events.py b/workos/resources/events.py index 8f39ba6d..3d97e679 100644 --- a/workos/resources/events.py +++ b/workos/resources/events.py @@ -1,17 +1,46 @@ from typing import Generic, Literal, TypeVar, Union from typing_extensions import Annotated from pydantic import Field +from workos.resources.directory_sync import DirectoryGroup from workos.resources.workos_model import WorkOSModel +from workos.types.directory_sync.directory_user import DirectoryUser +from workos.types.events.directory_group_membership_payload import ( + DirectoryGroupMembershipPayload, +) +from workos.types.events.directory_group_with_previous_attributes import ( + DirectoryGroupWithPreviousAttributes, +) from workos.types.events.directory_payload import DirectoryPayload from workos.types.events.directory_payload_with_legacy_fields import ( DirectoryPayloadWithLegacyFields, ) +from workos.types.events.directory_user_with_previous_attributes import ( + DirectoryUserWithPreviousAttributes, +) from workos.typing.literals import LiteralOrUntyped -EventType = Literal["dsync.activated", "dsync.deleted"] +EventType = Literal[ + "dsync.activated", + "dsync.deleted", + "dsync.group.created", + "dsync.group.deleted", + "dsync.group.updated", + "dsync.user.created", + "dsync.user.deleted", + "dsync.user.updated", + "dsync.group.user_added", + "dsync.group.user_removed", +] EventTypeDiscriminator = TypeVar("EventTypeDiscriminator", bound=EventType) EventPayload = TypeVar( - "EventPayload", DirectoryPayload, DirectoryPayloadWithLegacyFields + "EventPayload", + DirectoryPayload, + DirectoryPayloadWithLegacyFields, + DirectoryGroup, + DirectoryGroupWithPreviousAttributes, + DirectoryUser, + DirectoryUserWithPreviousAttributes, + DirectoryGroupMembershipPayload, ) @@ -39,7 +68,66 @@ class DirectoryDeletedEvent(EventModel[Literal["dsync.deleted"], DirectoryPayloa event: Literal["dsync.deleted"] +class DirectoryGroupCreatedEvent( + EventModel[Literal["dsync.group.created"], DirectoryGroup] +): + event: Literal["dsync.group.created"] + + +class DirectoryGroupDeletedEvent( + EventModel[Literal["dsync.group.deleted"], DirectoryGroup] +): + event: Literal["dsync.group.deleted"] + + +class DirectoryGroupUpdatedEvent( + EventModel[Literal["dsync.group.updated"], DirectoryGroupWithPreviousAttributes] +): + event: Literal["dsync.group.updated"] + + +class DirectoryUserCreatedEvent( + EventModel[Literal["dsync.user.created"], DirectoryUser] +): + event: Literal["dsync.user.created"] + + +class DirectoryUserDeletedEvent( + EventModel[Literal["dsync.user.deleted"], DirectoryUser] +): + event: Literal["dsync.user.deleted"] + + +class DirectoryUserUpdatedEvent( + EventModel[Literal["dsync.user.updated"], DirectoryUserWithPreviousAttributes] +): + event: Literal["dsync.user.updated"] + + +class DirectoryUserAddedToGroupEvent( + EventModel[Literal["dsync.group.user_added"], DirectoryGroupMembershipPayload] +): + event: Literal["dsync.group.user_added"] + + +class DirectoryUserRemovedFromGroupEvent( + EventModel[Literal["dsync.group.user_removed"], DirectoryGroupMembershipPayload] +): + event: Literal["dsync.group.user_removed"] + + Event = Annotated[ - Union[DirectoryActivatedEvent, DirectoryDeletedEvent], + Union[ + DirectoryActivatedEvent, + DirectoryDeletedEvent, + DirectoryGroupCreatedEvent, + DirectoryGroupDeletedEvent, + DirectoryGroupUpdatedEvent, + DirectoryUserCreatedEvent, + DirectoryUserDeletedEvent, + DirectoryUserUpdatedEvent, + DirectoryUserAddedToGroupEvent, + DirectoryUserRemovedFromGroupEvent, + ], Field(..., discriminator="event"), ] diff --git a/workos/resources/list.py b/workos/resources/list.py index 560eb6d5..971746c9 100644 --- a/workos/resources/list.py +++ b/workos/resources/list.py @@ -15,7 +15,12 @@ ) from typing_extensions import Required, TypedDict from workos.resources.base import WorkOSBaseResource -from workos.resources.directory_sync import Directory, DirectoryGroup, DirectoryUser +from workos.resources.directory_sync import ( + Directory, + DirectoryGroup, + DirectoryUser, + DirectoryUserWithGroups, +) from workos.resources.events import Event from workos.resources.organizations import Organization from pydantic import BaseModel, Field @@ -120,7 +125,7 @@ def auto_paging_iter(self): Connection, Directory, DirectoryGroup, - DirectoryUser, + DirectoryUserWithGroups, Event, Organization, ) diff --git a/workos/types/directory_sync/directory_user.py b/workos/types/directory_sync/directory_user.py new file mode 100644 index 00000000..086ee11c --- /dev/null +++ b/workos/types/directory_sync/directory_user.py @@ -0,0 +1,38 @@ +from typing import List, Literal, Optional + +from workos.resources.workos_model import WorkOSModel + + +DirectoryUserState = Literal["active", "inactive"] + + +class DirectoryUserEmail(WorkOSModel): + type: Optional[str] = None + value: Optional[str] = None + primary: Optional[bool] = None + + +class Role(WorkOSModel): + slug: str + + +class DirectoryUser(WorkOSModel): + id: str + object: Literal["directory_user"] + idp_id: str + directory_id: str + organization_id: str + first_name: Optional[str] = None + last_name: Optional[str] = None + job_title: Optional[str] = None + emails: List[DirectoryUserEmail] + username: Optional[str] = None + state: DirectoryUserState + custom_attributes: dict + raw_attributes: dict + created_at: str + updated_at: str + role: Optional[Role] = None + + def primary_email(self): + return next((email for email in self.emails if email.primary), None) diff --git a/workos/types/events/directory_group_membership_payload.py b/workos/types/events/directory_group_membership_payload.py new file mode 100644 index 00000000..07747765 --- /dev/null +++ b/workos/types/events/directory_group_membership_payload.py @@ -0,0 +1,9 @@ +from workos.resources.directory_sync import DirectoryGroup +from workos.resources.workos_model import WorkOSModel +from workos.types.directory_sync.directory_user import DirectoryUser + + +class DirectoryGroupMembershipPayload(WorkOSModel): + directory_id: str + user: DirectoryUser + group: DirectoryGroup diff --git a/workos/types/events/directory_group_with_previous_attributes.py b/workos/types/events/directory_group_with_previous_attributes.py new file mode 100644 index 00000000..bd5a49cb --- /dev/null +++ b/workos/types/events/directory_group_with_previous_attributes.py @@ -0,0 +1,6 @@ +from workos.resources.directory_sync import DirectoryGroup +from workos.types.events.previous_attributes import PreviousAttributes + + +class DirectoryGroupWithPreviousAttributes(DirectoryGroup): + previous_attributes: PreviousAttributes diff --git a/workos/types/events/directory_user_with_previous_attributes.py b/workos/types/events/directory_user_with_previous_attributes.py new file mode 100644 index 00000000..a87ba931 --- /dev/null +++ b/workos/types/events/directory_user_with_previous_attributes.py @@ -0,0 +1,6 @@ +from workos.types.directory_sync.directory_user import DirectoryUser +from workos.types.events.previous_attributes import PreviousAttributes + + +class DirectoryUserWithPreviousAttributes(DirectoryUser): + previous_attributes: PreviousAttributes diff --git a/workos/types/events/previous_attributes.py b/workos/types/events/previous_attributes.py new file mode 100644 index 00000000..dfcb52b3 --- /dev/null +++ b/workos/types/events/previous_attributes.py @@ -0,0 +1,3 @@ +from typing import Any, Mapping + +PreviousAttributes = Mapping[str, Any] From 09c0daeaa13df44c395393f7bb5e369d1f5debd9 Mon Sep 17 00:00:00 2001 From: pantera Date: Fri, 26 Jul 2024 10:04:53 -0700 Subject: [PATCH 09/18] List resource returns more specific type (#293) * List resource returns more specific type --- workos/directory_sync.py | 19 +++++++++++++------ workos/events.py | 3 ++- workos/organizations.py | 8 ++++---- workos/resources/list.py | 20 +++++++++++++------- workos/sso.py | 5 +++-- 5 files changed, 35 insertions(+), 20 deletions(-) diff --git a/workos/directory_sync.py b/workos/directory_sync.py index 6416371f..8671c2b6 100644 --- a/workos/directory_sync.py +++ b/workos/directory_sync.py @@ -16,6 +16,7 @@ ) from workos.resources.list import ( ListArgs, + ListMetadata, ListPage, AsyncWorkOsListResource, SyncOrAsyncListResource, @@ -100,7 +101,9 @@ def list_users( before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", - ) -> WorkOsListResource[DirectoryUserWithGroups, DirectoryUserListFilters]: + ) -> WorkOsListResource[ + DirectoryUserWithGroups, DirectoryUserListFilters, ListMetadata + ]: """Gets a list of provisioned Users for a Directory. Note, either 'directory' or 'group' must be provided. @@ -150,7 +153,7 @@ def list_groups( before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", - ) -> WorkOsListResource[DirectoryGroup, DirectoryGroupListFilters]: + ) -> WorkOsListResource[DirectoryGroup, DirectoryGroupListFilters, ListMetadata]: """Gets a list of provisioned Groups for a Directory . Note, either 'directory' or 'user' must be provided. @@ -252,7 +255,7 @@ def list_directories( after: Optional[str] = None, organization: Optional[str] = None, order: PaginationOrder = "desc", - ) -> WorkOsListResource[Directory, DirectoryListFilters]: + ) -> WorkOsListResource[Directory, DirectoryListFilters, ListMetadata]: """Gets details for existing Directories. Args: @@ -323,7 +326,9 @@ async def list_users( before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", - ) -> AsyncWorkOsListResource[DirectoryUserWithGroups, DirectoryUserListFilters]: + ) -> AsyncWorkOsListResource[ + DirectoryUserWithGroups, DirectoryUserListFilters, ListMetadata + ]: """Gets a list of provisioned Users for a Directory. Note, either 'directory' or 'group' must be provided. @@ -373,7 +378,9 @@ async def list_groups( before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", - ) -> AsyncWorkOsListResource[DirectoryGroup, DirectoryGroupListFilters]: + ) -> AsyncWorkOsListResource[ + DirectoryGroup, DirectoryGroupListFilters, ListMetadata + ]: """Gets a list of provisioned Groups for a Directory . Note, either 'directory' or 'user' must be provided. @@ -474,7 +481,7 @@ async def list_directories( after: Optional[str] = None, organization: Optional[str] = None, order: PaginationOrder = "desc", - ) -> AsyncWorkOsListResource[Directory, DirectoryListFilters]: + ) -> AsyncWorkOsListResource[Directory, DirectoryListFilters, ListMetadata]: """Gets details for existing Directories. Args: diff --git a/workos/events.py b/workos/events.py index 80e838f5..d9375332 100644 --- a/workos/events.py +++ b/workos/events.py @@ -7,6 +7,7 @@ from workos.utils.http_client import AsyncHTTPClient, SyncHTTPClient from workos.utils.validation import EVENTS_MODULE, validate_settings from workos.resources.list import ( + ListAfterMetadata, ListArgs, ListPage, WorkOsListResource, @@ -20,7 +21,7 @@ class EventsListFilters(ListArgs, total=False): range_end: Optional[str] -EventsListResource = WorkOsListResource[Event, EventsListFilters] +EventsListResource = WorkOsListResource[Event, EventsListFilters, ListAfterMetadata] class EventsModule(Protocol): diff --git a/workos/organizations.py b/workos/organizations.py index a43baf30..d01e872f 100644 --- a/workos/organizations.py +++ b/workos/organizations.py @@ -14,7 +14,7 @@ Organization, DomainDataInput, ) -from workos.resources.list import ListPage, WorkOsListResource, ListArgs +from workos.resources.list import ListMetadata, ListPage, WorkOsListResource, ListArgs ORGANIZATIONS_PATH = "organizations" @@ -31,7 +31,7 @@ def list_organizations( before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", - ) -> WorkOsListResource[Organization, OrganizationListFilters]: ... + ) -> WorkOsListResource[Organization, OrganizationListFilters, ListMetadata]: ... def get_organization(self, organization: str) -> Organization: ... @@ -72,7 +72,7 @@ def list_organizations( before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", - ) -> WorkOsListResource[Organization, OrganizationListFilters]: + ) -> WorkOsListResource[Organization, OrganizationListFilters, ListMetadata]: """Retrieve a list of organizations that have connections configured within your WorkOS dashboard. Kwargs: @@ -101,7 +101,7 @@ def list_organizations( token=workos.api_key, ) - return WorkOsListResource[Organization, OrganizationListFilters]( + return WorkOsListResource[Organization, OrganizationListFilters, ListMetadata]( list_method=self.list_organizations, list_args=list_params, **ListPage[Organization](**response).model_dump() diff --git a/workos/resources/list.py b/workos/resources/list.py index 971746c9..addc7cfb 100644 --- a/workos/resources/list.py +++ b/workos/resources/list.py @@ -139,6 +139,9 @@ class ListMetadata(ListAfterMetadata): before: Optional[str] = None +ListMetadataType = TypeVar("ListMetadataType", ListAfterMetadata, ListMetadata) + + class ListPage(WorkOSModel, Generic[ListableResource]): object: Literal["list"] data: List[ListableResource] @@ -153,16 +156,15 @@ class ListArgs(TypedDict, total=False): ListAndFilterParams = TypeVar("ListAndFilterParams", bound=ListArgs) -ListMetadataType = TypeVar("ListMetadataType", ListAfterMetadata, ListMetadata) class BaseWorkOsListResource( WorkOSModel, - Generic[ListableResource, ListAndFilterParams], + Generic[ListableResource, ListAndFilterParams, ListMetadataType], ): object: Literal["list"] data: List[ListableResource] - list_metadata: Union[ListAfterMetadata, ListMetadata] + list_metadata: ListMetadataType list_method: Callable = Field(exclude=True) list_args: ListAndFilterParams = Field(exclude=True) @@ -195,10 +197,12 @@ def auto_paging_iter( class WorkOsListResource( BaseWorkOsListResource, - Generic[ListableResource, ListAndFilterParams], + Generic[ListableResource, ListAndFilterParams, ListMetadataType], ): def auto_paging_iter(self) -> Iterator[ListableResource]: - next_page: WorkOsListResource[ListableResource, ListAndFilterParams] + next_page: WorkOsListResource[ + ListableResource, ListAndFilterParams, ListMetadataType + ] after = self.list_metadata.after fixed_pagination_params, filter_params = self._parse_params() index: int = 0 @@ -221,10 +225,12 @@ def auto_paging_iter(self) -> Iterator[ListableResource]: class AsyncWorkOsListResource( BaseWorkOsListResource, - Generic[ListableResource, ListAndFilterParams], + Generic[ListableResource, ListAndFilterParams, ListMetadataType], ): async def auto_paging_iter(self) -> AsyncIterator[ListableResource]: - next_page: WorkOsListResource[ListableResource, ListAndFilterParams] + next_page: WorkOsListResource[ + ListableResource, ListAndFilterParams, ListMetadataType + ] after = self.list_metadata.after fixed_pagination_params, filter_params = self._parse_params() index: int = 0 diff --git a/workos/sso.py b/workos/sso.py index 293b69a5..26df7728 100644 --- a/workos/sso.py +++ b/workos/sso.py @@ -23,6 +23,7 @@ from workos.resources.list import ( AsyncWorkOsListResource, ListArgs, + ListMetadata, ListPage, SyncOrAsyncListResource, WorkOsListResource, @@ -194,7 +195,7 @@ def list_connections( before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", - ) -> WorkOsListResource[Connection, ConnectionsListFilters]: + ) -> WorkOsListResource[Connection, ConnectionsListFilters, ListMetadata]: """Gets details for existing Connections. Args: @@ -321,7 +322,7 @@ async def list_connections( before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", - ) -> AsyncWorkOsListResource[Connection, ConnectionsListFilters]: + ) -> AsyncWorkOsListResource[Connection, ConnectionsListFilters, ListMetadata]: """Gets details for existing Connections. Args: From 4439d6c64114540464a8bb456190658cfeec0485 Mon Sep 17 00:00:00 2001 From: Matt Dzwonczyk <9063128+mattgd@users.noreply.github.com> Date: Mon, 29 Jul 2024 13:40:51 -0400 Subject: [PATCH 10/18] Add typing for User Management (#294) --- tests/conftest.py | 39 +- tests/test_sso.py | 11 +- tests/test_user_management.py | 1111 ++++++-------- tests/utils/fixtures/mock_auth_factor_totp.py | 9 +- tests/utils/fixtures/mock_connection.py | 5 +- .../utils/fixtures/mock_email_verification.py | 9 +- tests/utils/fixtures/mock_invitation.py | 9 +- tests/utils/fixtures/mock_magic_auth.py | 9 +- tests/utils/fixtures/mock_organization.py | 5 +- .../fixtures/mock_organization_membership.py | 7 +- tests/utils/fixtures/mock_password_reset.py | 7 +- tests/utils/fixtures/mock_user.py | 12 +- workos/client.py | 2 +- workos/directory_sync.py | 30 +- workos/events.py | 12 +- workos/resources/list.py | 7 +- workos/resources/mfa.py | 77 + workos/resources/organizations.py | 3 +- workos/resources/user_management.py | 335 ++-- workos/sso.py | 20 +- .../directory_payload_with_legacy_fields.py | 1 - workos/user_management.py | 1344 +++++++---------- workos/utils/_base_http_client.py | 4 +- workos/utils/request.py | 4 +- workos/utils/um_provider_types.py | 11 +- 25 files changed, 1360 insertions(+), 1723 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index bf96ee24..3be3b68a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,12 +1,13 @@ -from typing import Mapping, Optional, Union +from typing import Any, Callable, Mapping, Optional, Union from unittest.mock import AsyncMock, MagicMock import httpx import pytest import requests -from tests.utils.list_resource import list_response_of +from tests.utils.list_resource import list_data_to_dicts, list_response_of import workos +from workos.resources.list import WorkOsListResource from workos.utils.http_client import AsyncHTTPClient, SyncHTTPClient @@ -149,15 +150,13 @@ def inner( def capture_and_mock_http_client_request(monkeypatch): def inner( http_client: Union[SyncHTTPClient, AsyncHTTPClient], - response_dict: dict, + response_dict: Optional[dict] = None, status_code: int = 200, headers: Optional[Mapping[str, str]] = None, ): - request_args = [] request_kwargs = {} def capture_and_mock(*args, **kwargs): - request_args.extend(args) request_kwargs.update(kwargs) return httpx.Response( @@ -173,7 +172,7 @@ def capture_and_mock(*args, **kwargs): monkeypatch.setattr(http_client._client, "request", mock) - return (request_args, request_kwargs) + return request_kwargs return inner @@ -224,3 +223,31 @@ def mock_function(*args, **kwargs): monkeypatch.setattr(http_client._client, "request", mock) return inner + + +@pytest.fixture +def test_sync_auto_pagination( + mock_pagination_request_for_http_client, +): + def inner( + http_client: Union[SyncHTTPClient, AsyncHTTPClient], + list_function: Callable[[], WorkOsListResource], + expected_all_page_data: dict, + list_function_params: Optional[Mapping[str, Any]] = None, + ): + mock_pagination_request_for_http_client( + http_client=http_client, + data_list=expected_all_page_data, + status_code=200, + ) + + results = list_function(**list_function_params or {}) + all_results = [] + + for result in results.auto_paging_iter(): + all_results.append(result) + + assert len(list(all_results)) == len(expected_all_page_data) + assert (list_data_to_dicts(all_results)) == expected_all_page_data + + return inner diff --git a/tests/test_sso.py b/tests/test_sso.py index d5bac063..7b2f419e 100644 --- a/tests/test_sso.py +++ b/tests/test_sso.py @@ -100,6 +100,7 @@ def test_authorization_url_has_expected_query_params_with_provider(self): parsed_url = urlparse(authorization_url) + assert parsed_url.path == "/sso/authorize" assert dict(parse_qsl(parsed_url.query)) == { "provider": self.provider, "client_id": workos.client_id, @@ -118,6 +119,7 @@ def test_authorization_url_has_expected_query_params_with_domain_hint(self): parsed_url = urlparse(authorization_url) + assert parsed_url.path == "/sso/authorize" assert dict(parse_qsl(parsed_url.query)) == { "domain_hint": self.customer_domain, "client_id": workos.client_id, @@ -137,6 +139,7 @@ def test_authorization_url_has_expected_query_params_with_login_hint(self): parsed_url = urlparse(authorization_url) + assert parsed_url.path == "/sso/authorize" assert dict(parse_qsl(parsed_url.query)) == { "login_hint": self.login_hint, "client_id": workos.client_id, @@ -155,6 +158,7 @@ def test_authorization_url_has_expected_query_params_with_connection(self): parsed_url = urlparse(authorization_url) + assert parsed_url.path == "/sso/authorize" assert dict(parse_qsl(parsed_url.query)) == { "connection": self.connection_id, "client_id": workos.client_id, @@ -175,6 +179,7 @@ def test_authorization_url_with_string_provider_has_expected_query_params_with_o parsed_url = urlparse(authorization_url) + assert parsed_url.path == "/sso/authorize" assert dict(parse_qsl(parsed_url.query)) == { "organization": self.organization_id, "provider": self.provider, @@ -193,6 +198,7 @@ def test_authorization_url_has_expected_query_params_with_organization(self): parsed_url = urlparse(authorization_url) + assert parsed_url.path == "/sso/authorize" assert dict(parse_qsl(parsed_url.query)) == { "organization": self.organization_id, "client_id": workos.client_id, @@ -213,6 +219,7 @@ def test_authorization_url_has_expected_query_params_with_organization_and_provi parsed_url = urlparse(authorization_url) + assert parsed_url.path == "/sso/authorize" assert dict(parse_qsl(parsed_url.query)) == { "organization": self.organization_id, "provider": self.provider, @@ -312,7 +319,7 @@ def test_list_connections(self, mock_connections, mock_http_client_with_response def test_list_connections_with_connection_type( self, mock_connections, capture_and_mock_http_client_request ): - _, request_kwargs = capture_and_mock_http_client_request( + request_kwargs = capture_and_mock_http_client_request( http_client=self.http_client, response_dict=mock_connections, status_code=200, @@ -454,7 +461,7 @@ async def test_list_connections( async def test_list_connections_with_connection_type( self, mock_connections, capture_and_mock_http_client_request ): - _, request_kwargs = capture_and_mock_http_client_request( + request_kwargs = capture_and_mock_http_client_request( http_client=self.http_client, response_dict=mock_connections, status_code=200, diff --git a/tests/test_user_management.py b/tests/test_user_management.py index fe33bef4..9b40c8ec 100644 --- a/tests/test_user_management.py +++ b/tests/test_user_management.py @@ -1,7 +1,7 @@ import json + from six.moves.urllib.parse import parse_qsl, urlparse import pytest -import workos from tests.utils.fixtures.mock_auth_factor_totp import MockAuthFactorTotp from tests.utils.fixtures.mock_email_verification import MockEmailVerification @@ -9,133 +9,42 @@ from tests.utils.fixtures.mock_magic_auth import MockMagicAuth from tests.utils.fixtures.mock_organization_membership import MockOrganizationMembership from tests.utils.fixtures.mock_password_reset import MockPasswordReset -from tests.utils.fixtures.mock_session import MockSession from tests.utils.fixtures.mock_user import MockUser + +from tests.utils.list_resource import list_response_of +import workos from workos.user_management import UserManagement -from workos.utils.um_provider_types import UserManagementProviderType +from workos.utils.http_client import SyncHTTPClient from workos.utils.request import RESPONSE_TYPE_CODE class TestUserManagement(object): @pytest.fixture(autouse=True) def setup(self, set_api_key, set_client_id): - self.user_management = UserManagement() + self.http_client = SyncHTTPClient( + base_url="https://api.workos.test", version="test" + ) + self.user_management = UserManagement(http_client=self.http_client) @pytest.fixture def mock_user(self): return MockUser("user_01H7ZGXFP5C6BBQY6Z7277ZCT0").to_dict() @pytest.fixture - def mock_users(self): - user_list = [MockUser(id=str(i)).to_dict() for i in range(5000)] - - dict_response = { - "data": user_list, - "list_metadata": {"before": None, "after": None}, - "metadata": { - "params": { - "organization_id": None, - "email": None, - "limit": None, - "before": None, - "after": None, - "order": None, - "default_limit": True, - }, - "method": UserManagement.list_users, - }, - } - return dict_response - - @pytest.fixture - def mock_users_with_limit(self): - user_list = [MockUser(id=str(i)).to_dict() for i in range(4)] - dict_response = { - "data": user_list, - "list_metadata": {"before": None, "after": None}, - "metadata": { - "params": { - "type": None, - "organization_id": None, - "email": None, - "limit": 4, - "before": None, - "after": None, - "order": None, - }, - "method": UserManagement.list_users, - }, - } - return self.user_management.construct_from_response(dict_response) - - @pytest.fixture - def mock_users_with_default_limit(self): - user_list = [MockUser(id=str(i)).to_dict() for i in range(10)] - - dict_response = { - "data": user_list, - "list_metadata": {"before": None, "after": "user_id_xxx"}, - "metadata": { - "params": { - "type": None, - "organization_id": None, - "email": None, - "limit": None, - "before": None, - "after": None, - "order": None, - "default_limit": True, - }, - "method": UserManagement.list_users, - }, - } - return self.user_management.construct_from_response(dict_response) - - @pytest.fixture - def mock_users_pagination_response(self): - user_list = [MockUser(id=str(i)).to_dict() for i in range(4990)] - - return { - "data": user_list, - "list_metadata": {"before": None, "after": None}, - "metadata": { - "params": { - "domains": None, - "limit": None, - "before": None, - "after": None, - "order": None, - "default_limit": True, - }, - "method": UserManagement.list_users, - }, - } + def mock_users_multiple_pages(self): + users_list = [MockUser(id=str(i)).to_dict() for i in range(40)] + return list_response_of(data=users_list) @pytest.fixture def mock_organization_membership(self): return MockOrganizationMembership("om_ABCDE").to_dict() @pytest.fixture - def mock_organization_memberships(self): - om_list = [MockOrganizationMembership(id=str(i)).to_dict() for i in range(50)] - - dict_response = { - "data": om_list, - "list_metadata": {"before": None, "after": None}, - "metadata": { - "params": { - "user_id": None, - "organization_id": None, - "limit": None, - "before": None, - "after": None, - "order": None, - "default_limit": True, - }, - "method": UserManagement.list_organization_memberships, - }, - } - return dict_response + def mock_organization_memberships_multiple_pages(self): + organization_memberships_list = [ + MockOrganizationMembership(id=str(i)).to_dict() for i in range(40) + ] + return list_response_of(data=organization_memberships_list) @pytest.fixture def mock_auth_response(self): @@ -148,6 +57,13 @@ def mock_auth_response(self): "refresh_token": "refresh_token_12345", } + @pytest.fixture + def base_authentication_params(self): + return { + "client_id": "client_b27needthisforssotemxo", + "client_secret": "sk_test", + } + @pytest.fixture def mock_auth_refresh_token_response(self): return { @@ -161,6 +77,8 @@ def mock_auth_response_with_impersonator(self): return { "user": user, + "access_token": "access_token_12345", + "refresh_token": "refresh_token_12345", "organization_id": "org_12345", "impersonator": { "email": "admin@foocorp.com", @@ -180,10 +98,13 @@ def mock_enroll_auth_factor_response(self): "authentication_factor": { "object": "authentication_factor", "id": "auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ", + "user_id": "user_12345", "created_at": "2022-02-15T15:14:19.392Z", "updated_at": "2022-02-15T15:14:19.392Z", "type": "totp", "totp": { + "issuer": "FooCorp", + "user": "test@example.com", "qr_code": "data:image/png;base64,{base64EncodedPng}", "secret": "NAGCCFS3EYRB422HNAKAKY3XDUORMSRF", "uri": "otpauth://totp/FooCorp:alan.turing@foo-corp.com?secret=NAGCCFS3EYRB422HNAKAKY3XDUORMSRF&issuer=FooCorp", @@ -195,25 +116,15 @@ def mock_enroll_auth_factor_response(self): "created_at": "2022-02-15T15:26:53.274Z", "updated_at": "2022-02-15T15:26:53.274Z", "expires_at": "2022-02-15T15:36:53.279Z", + "code": None, "authentication_factor_id": "auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ", }, } @pytest.fixture - def mock_auth_factors(self): - auth_factors_list = [MockAuthFactorTotp(id=str(i)).to_dict() for i in range(2)] - - dict_response = { - "data": auth_factors_list, - "list_metadata": {"before": None, "after": None}, - "metadata": { - "params": { - "user_id": "user_12345", - }, - "method": UserManagement.list_auth_factors, - }, - } - return dict_response + def mock_auth_factors_multiple_pages(self): + auth_factors_list = [MockAuthFactorTotp(id=str(i)).to_dict() for i in range(40)] + return list_response_of(data=auth_factors_list) @pytest.fixture def mock_email_verification(self): @@ -232,79 +143,34 @@ def mock_invitation(self): return MockInvitation("invitation_ABCDE").to_dict() @pytest.fixture - def mock_invitations(self): - invitation_list = [MockInvitation(id=str(i)).to_dict() for i in range(50)] - - dict_response = { - "data": invitation_list, - "list_metadata": {"before": None, "after": None}, - "metadata": { - "params": { - "email": None, - "organization_id": None, - "limit": None, - "before": None, - "after": None, - "order": None, - "default_limit": True, - }, - "method": UserManagement.list_invitations, - }, - } - return dict_response + def mock_invitations_multiple_pages(self): + invitations_list = [MockInvitation(id=str(i)).to_dict() for i in range(40)] + return list_response_of(data=invitations_list) - def test_get_user(self, mock_user, capture_and_mock_request): - request_args, request_kwargs = capture_and_mock_request("get", mock_user, 200) + def test_get_user(self, mock_user, capture_and_mock_http_client_request): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_user, 200 + ) user = self.user_management.get_user("user_01H7ZGXFP5C6BBQY6Z7277ZCT0") - assert request_args[1].endswith( + + assert request_kwargs["url"].endswith( "user_management/users/user_01H7ZGXFP5C6BBQY6Z7277ZCT0" ) - assert user["id"] == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" - assert user["profile_picture_url"] == "https://example.com/profile-picture.jpg" + assert user.id == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" + assert user.profile_picture_url == "https://example.com/profile-picture.jpg" def test_list_users_auto_pagination( - self, - mock_users_with_default_limit, - mock_users_pagination_response, - mock_users, - mock_request_method, - ): - mock_request_method("get", mock_users_pagination_response, 200) - users = mock_users_with_default_limit - all_users = users.auto_paging_iter() - assert len(*list(all_users)) == len(mock_users["data"]) - - def test_list_users_honors_limit( - self, - mock_users_with_limit, - mock_users_pagination_response, - mock_request_method, - ): - mock_request_method("get", mock_users_pagination_response, 200) - users = mock_users_with_limit - all_users = users.auto_paging_iter() - dict_response = users.to_dict() - assert len(*list(all_users)) == len(dict_response["data"]) - - def test_list_users_returns_metadata( - self, - mock_users, - mock_request_method, + self, mock_users_multiple_pages, test_sync_auto_pagination ): - mock_request_method("get", mock_users, 200) - - users = self.user_management.list_users( - email="marcelina@foo-corp.com", - organization_id="org_12345", + test_sync_auto_pagination( + http_client=self.http_client, + list_function=self.user_management.list_users, + expected_all_page_data=mock_users_multiple_pages["data"], ) - dict_users = users.to_dict() - assert dict_users["metadata"]["params"]["email"] == "marcelina@foo-corp.com" - assert dict_users["metadata"]["params"]["organization_id"] == "org_12345" - - def test_create_user(self, mock_user, mock_request_method): - mock_request_method("post", mock_user, 201) + def test_create_user(self, mock_user, mock_http_client_with_response): + mock_http_client_with_response(self.http_client, mock_user, 201) payload = { "email": "marcelina@foo-corp.com", @@ -313,62 +179,68 @@ def test_create_user(self, mock_user, mock_request_method): "password": "password", "email_verified": False, } - user = self.user_management.create_user(payload) + user = self.user_management.create_user(**payload) - assert user["id"] == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" + assert user.id == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" - def test_update_user(self, mock_user, capture_and_mock_request): - request_args, request = capture_and_mock_request("put", mock_user, 200) + def test_update_user(self, mock_user, capture_and_mock_http_client_request): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_user, 200 + ) + params = { + "first_name": "Marcelina", + "last_name": "Hoeger", + "email_verified": True, + "password": "password", + } user = self.user_management.update_user( - "user_01H7ZGXFP5C6BBQY6Z7277ZCT0", - { - "first_name": "Marcelina", - "last_name": "Hoeger", - "email_verified": True, - "password": "password", - }, + "user_01H7ZGXFP5C6BBQY6Z7277ZCT0", **params ) - assert request_args[1].endswith("users/user_01H7ZGXFP5C6BBQY6Z7277ZCT0") - assert user["id"] == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" - assert request["json"]["first_name"] == "Marcelina" - assert request["json"]["last_name"] == "Hoeger" - assert request["json"]["email_verified"] == True - assert request["json"]["password"] == "password" + assert request_kwargs["url"].endswith("users/user_01H7ZGXFP5C6BBQY6Z7277ZCT0") + assert user.id == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" + assert request_kwargs["json"]["first_name"] == "Marcelina" + assert request_kwargs["json"]["last_name"] == "Hoeger" + assert request_kwargs["json"]["email_verified"] == True + assert request_kwargs["json"]["password"] == "password" - def test_delete_user(self, capture_and_mock_request): - request_args, request_kwargs = capture_and_mock_request("delete", None, 200) + def test_delete_user(self, capture_and_mock_http_client_request): + request_kwargs = capture_and_mock_http_client_request( + http_client=self.http_client, status_code=204 + ) user = self.user_management.delete_user("user_01H7ZGXFP5C6BBQY6Z7277ZCT0") - assert request_args[1].endswith( + assert request_kwargs["url"].endswith( "user_management/users/user_01H7ZGXFP5C6BBQY6Z7277ZCT0" ) assert user is None def test_create_organization_membership( - self, capture_and_mock_request, mock_organization_membership + self, capture_and_mock_http_client_request, mock_organization_membership ): user_id = "user_12345" organization_id = "org_67890" - request_args, _ = capture_and_mock_request( - "post", mock_organization_membership, 201 + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_organization_membership, 201 ) organization_membership = self.user_management.create_organization_membership( user_id=user_id, organization_id=organization_id ) - assert request_args[1].endswith("user_management/organization_memberships") - assert organization_membership["user_id"] == user_id - assert organization_membership["organization_id"] == organization_id + assert request_kwargs["url"].endswith( + "user_management/organization_memberships" + ) + assert organization_membership.user_id == user_id + assert organization_membership.organization_id == organization_id def test_update_organization_membership( - self, capture_and_mock_request, mock_organization_membership + self, capture_and_mock_http_client_request, mock_organization_membership ): - request_args, _ = capture_and_mock_request( - "put", mock_organization_membership, 201 + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_organization_membership, 201 ) organization_membership = self.user_management.update_organization_membership( @@ -376,122 +248,74 @@ def test_update_organization_membership( role_slug="member", ) - assert request_args[1].endswith( + assert request_kwargs["url"].endswith( "user_management/organization_memberships/om_ABCDE" ) - assert organization_membership["id"] == "om_ABCDE" - assert organization_membership["role"] == {"slug": "member"} + assert organization_membership.id == "om_ABCDE" + assert organization_membership.role == {"slug": "member"} def test_get_organization_membership( - self, mock_organization_membership, capture_and_mock_request + self, mock_organization_membership, capture_and_mock_http_client_request ): - request_args, request_kwargs = capture_and_mock_request( - "get", mock_organization_membership, 200 + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_organization_membership, 200 ) om = self.user_management.get_organization_membership("om_ABCDE") - assert request_args[1].endswith( + assert request_kwargs["url"].endswith( "user_management/organization_memberships/om_ABCDE" ) - assert om["id"] == "om_ABCDE" - - def test_list_organization_memberships_returns_metadata( - self, - mock_organization_memberships, - mock_request_method, - ): - mock_request_method("get", mock_organization_memberships, 200) - - oms = self.user_management.list_organization_memberships( - organization_id="org_12345", - ) - - dict_oms = oms.to_dict() - assert dict_oms["metadata"]["params"]["organization_id"] == "org_12345" - - def test_list_organization_memberships_with_multiple_statuses_returns_metadata( - self, mock_organization_memberships, capture_and_mock_request - ): - _, request_kwargs = capture_and_mock_request( - "get", mock_organization_memberships, 200 - ) - - oms = self.user_management.list_organization_memberships( - organization_id="org_12345", - statuses=["active", "inactive"], - ) - - assert request_kwargs["params"]["statuses"] == "active,inactive" - dict_oms = oms.to_dict() - assert dict_oms["metadata"]["params"]["organization_id"] == "org_12345" + assert om.id == "om_ABCDE" - def test_list_organization_memberships_with_a_single_status_returns_metadata( - self, mock_organization_memberships, capture_and_mock_request - ): - _, request_kwargs = capture_and_mock_request( - "get", mock_organization_memberships, 200 + def test_delete_organization_membership(self, capture_and_mock_http_client_request): + request_kwargs = capture_and_mock_http_client_request( + http_client=self.http_client, status_code=200 ) - oms = self.user_management.list_organization_memberships( - organization_id="org_12345", - statuses=["inactive"], - ) - - assert request_kwargs["params"]["statuses"] == "inactive" - dict_oms = oms.to_dict() - assert dict_oms["metadata"]["params"]["organization_id"] == "org_12345" - - def test_delete_organization_membership(self, capture_and_mock_request): - request_args, request_kwargs = capture_and_mock_request("delete", None, 200) - user = self.user_management.delete_organization_membership("om_ABCDE") - assert request_args[1].endswith( + assert request_kwargs["url"].endswith( "user_management/organization_memberships/om_ABCDE" ) assert user is None + def test_list_organization_memberships_auto_pagination( + self, mock_organization_memberships_multiple_pages, test_sync_auto_pagination + ): + test_sync_auto_pagination( + http_client=self.http_client, + list_function=self.user_management.list_organization_memberships, + expected_all_page_data=mock_organization_memberships_multiple_pages["data"], + ) + def test_deactivate_organization_membership( - self, mock_organization_membership, capture_and_mock_request + self, mock_organization_membership, capture_and_mock_http_client_request ): - request_args, request_kwargs = capture_and_mock_request( - "put", mock_organization_membership, 200 + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_organization_membership, 200 ) om = self.user_management.deactivate_organization_membership("om_ABCDE") - assert request_args[1].endswith( + assert request_kwargs["url"].endswith( "user_management/organization_memberships/om_ABCDE/deactivate" ) - assert om["id"] == "om_ABCDE" + assert om.id == "om_ABCDE" def test_reactivate_organization_membership( - self, mock_organization_membership, capture_and_mock_request + self, mock_organization_membership, capture_and_mock_http_client_request ): - request_args, request_kwargs = capture_and_mock_request( - "put", mock_organization_membership, 200 + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_organization_membership, 200 ) om = self.user_management.reactivate_organization_membership("om_ABCDE") - assert request_args[1].endswith( + assert request_kwargs["url"].endswith( "user_management/organization_memberships/om_ABCDE/reactivate" ) - assert om["id"] == "om_ABCDE" - - def test_authorization_url_throws_value_error_without_redirect_uri(self): - connection_id = "connection_123" - login_hint = "foo@workos.com" - state = json.dumps({"things": "with_stuff"}) - with pytest.raises(TypeError): - self.user_management.get_authorization_url( - connection_id=connection_id, - login_hint=login_hint, - state=state, - ) # type: ignore - # TODO: ignore above added temporarily, this runtime error isn't needed with modern python type validation - # leaving this as a reminder to remove the runtime check. + assert om.id == "om_ABCDE" def test_authorization_url_throws_value_error_with_missing_connection_organization_and_provider( self, @@ -500,29 +324,6 @@ def test_authorization_url_throws_value_error_with_missing_connection_organizati with pytest.raises(ValueError, match=r"Incomplete arguments.*"): self.user_management.get_authorization_url(redirect_uri=redirect_uri) - @pytest.mark.parametrize( - "invalid_provider", - [ - 123, - UserManagementProviderType, - True, - False, - {"provider": "GoogleOAuth"}, - ["GoogleOAuth"], - ], - ) - def test_authorization_url_throws_value_error_with_incorrect_provider_type( - self, invalid_provider - ): - with pytest.raises( - ValueError, match="'provider' must be of type UserManagementProviderType" - ): - redirect_uri = "https://localhost/auth/callback" - self.user_management.get_authorization_url( - provider=invalid_provider, - redirect_uri=redirect_uri, - ) - def test_authorization_url_has_expected_query_params_with_connection_id(self): connection_id = "connection_123" redirect_uri = "https://localhost/auth/callback" @@ -532,6 +333,7 @@ def test_authorization_url_has_expected_query_params_with_connection_id(self): ) parsed_url = urlparse(authorization_url) + assert parsed_url.path == "/user_management/authorize" assert dict(parse_qsl(str(parsed_url.query))) == { "connection_id": connection_id, "client_id": workos.client_id, @@ -548,6 +350,7 @@ def test_authorization_url_has_expected_query_params_with_organization_id(self): ) parsed_url = urlparse(authorization_url) + assert parsed_url.path == "/user_management/authorize" assert dict(parse_qsl(str(parsed_url.query))) == { "organization_id": organization_id, "client_id": workos.client_id, @@ -556,15 +359,16 @@ def test_authorization_url_has_expected_query_params_with_organization_id(self): } def test_authorization_url_has_expected_query_params_with_provider(self): - provider = UserManagementProviderType.GoogleOAuth + provider = "GoogleOAuth" redirect_uri = "https://localhost/auth/callback" authorization_url = self.user_management.get_authorization_url( provider=provider, redirect_uri=redirect_uri ) parsed_url = urlparse(authorization_url) + assert parsed_url.path == "/user_management/authorize" assert dict(parse_qsl(str(parsed_url.query))) == { - "provider": provider.value, + "provider": provider, "client_id": workos.client_id, "redirect_uri": redirect_uri, "response_type": RESPONSE_TYPE_CODE, @@ -582,6 +386,7 @@ def test_authorization_url_has_expected_query_params_with_domain_hint(self): ) parsed_url = urlparse(authorization_url) + assert parsed_url.path == "/user_management/authorize" assert dict(parse_qsl(str(parsed_url.query))) == { "domain_hint": domain_hint, "client_id": workos.client_id, @@ -602,6 +407,7 @@ def test_authorization_url_has_expected_query_params_with_login_hint(self): ) parsed_url = urlparse(authorization_url) + assert parsed_url.path == "/user_management/authorize" assert dict(parse_qsl(str(parsed_url.query))) == { "login_hint": login_hint, "client_id": workos.client_id, @@ -622,6 +428,7 @@ def test_authorization_url_has_expected_query_params_with_state(self): ) parsed_url = urlparse(authorization_url) + assert parsed_url.path == "/user_management/authorize" assert dict(parse_qsl(str(parsed_url.query))) == { "state": state, "client_id": workos.client_id, @@ -642,6 +449,7 @@ def test_authorization_url_has_expected_query_params_with_code_challenge(self): ) parsed_url = urlparse(authorization_url) + assert parsed_url.path == "/user_management/authorize" assert dict(parse_qsl(str(parsed_url.query))) == { "code_challenge": code_challenge, "code_challenge_method": "S256", @@ -652,261 +460,240 @@ def test_authorization_url_has_expected_query_params_with_code_challenge(self): } def test_authenticate_with_password( - self, capture_and_mock_request, mock_auth_response + self, + capture_and_mock_http_client_request, + mock_auth_response, + base_authentication_params, ): - email = "marcelina@foo-corp.com" - password = "test123" - user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" - ip_address = "192.0.0.1" - - request_args, request = capture_and_mock_request( - "post", mock_auth_response, 200 - ) - - response = self.user_management.authenticate_with_password( - email=email, - password=password, - user_agent=user_agent, - ip_address=ip_address, - ) - - assert request_args[1].endswith("user_management/authenticate") - assert response["user"]["id"] == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" - assert response["organization_id"] == "org_12345" - assert response["access_token"] == "access_token_12345" - assert response["refresh_token"] == "refresh_token_12345" - assert request["json"]["email"] == email - assert request["json"]["password"] == password - assert request["json"]["user_agent"] == user_agent - assert request["json"]["ip_address"] == ip_address - assert request["json"]["client_id"] == "client_b27needthisforssotemxo" - assert request["json"]["client_secret"] == "sk_test" - assert request["json"]["grant_type"] == "password" - - def test_authenticate_with_code(self, capture_and_mock_request, mock_auth_response): - code = "test_code" - code_verifier = "test_code_verifier" - user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" - ip_address = "192.0.0.1" - - request_args, request = capture_and_mock_request( - "post", mock_auth_response, 200 - ) - - response = self.user_management.authenticate_with_code( - code=code, - code_verifier=code_verifier, - user_agent=user_agent, - ip_address=ip_address, - ) - - assert request_args[1].endswith("user_management/authenticate") - assert response["user"]["id"] == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" - assert response["organization_id"] == "org_12345" - assert response["access_token"] == "access_token_12345" - assert response["refresh_token"] == "refresh_token_12345" - assert request["json"]["code"] == code - assert request["json"]["code_verifier"] == code_verifier - assert request["json"]["user_agent"] == user_agent - assert request["json"]["ip_address"] == ip_address - assert request["json"]["client_id"] == "client_b27needthisforssotemxo" - assert request["json"]["client_secret"] == "sk_test" - assert request["json"]["grant_type"] == "authorization_code" + params = { + "email": "marcelina@foo-corp.com", + "password": "test123", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36", + "ip_address": "192.0.0.1", + } + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_auth_response, 200 + ) + + response = self.user_management.authenticate_with_password(**params) + + assert request_kwargs["url"].endswith("user_management/authenticate") + assert response.user.id == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" + assert response.organization_id == "org_12345" + assert response.access_token == "access_token_12345" + assert response.refresh_token == "refresh_token_12345" + assert request_kwargs["json"] == { + **params, + **base_authentication_params, + "grant_type": "password", + } + + def test_authenticate_with_code( + self, + capture_and_mock_http_client_request, + mock_auth_response, + base_authentication_params, + ): + params = { + "code": "test_code", + "code_verifier": "test_code_verifier", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36", + "ip_address": "192.0.0.1", + } + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_auth_response, 200 + ) + + response = self.user_management.authenticate_with_code(**params) + + assert request_kwargs["url"].endswith("user_management/authenticate") + assert response.user.id == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" + assert response.organization_id == "org_12345" + assert response.access_token == "access_token_12345" + assert response.refresh_token == "refresh_token_12345" + assert request_kwargs["json"] == { + **params, + **base_authentication_params, + "grant_type": "authorization_code", + } def test_authenticate_impersonator_with_code( - self, capture_and_mock_request, mock_auth_response_with_impersonator + self, + capture_and_mock_http_client_request, + mock_auth_response_with_impersonator, + base_authentication_params, ): - code = "test_code" + params = {"code": "test_code"} - request_args, request = capture_and_mock_request( - "post", mock_auth_response_with_impersonator, 200 + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_auth_response_with_impersonator, 200 ) - response = self.user_management.authenticate_with_code( - code=code, - ) + response = self.user_management.authenticate_with_code(**params) - assert request_args[1].endswith("user_management/authenticate") - assert response["user"]["id"] == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" - assert response["impersonator"]["email"] == "admin@foocorp.com" - assert response["impersonator"]["reason"] == "Debugging an account issue." + assert request_kwargs["url"].endswith("user_management/authenticate") + assert response.user.id == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" + assert response.impersonator is not None + assert response.impersonator.dict() == { + "email": "admin@foocorp.com", + "reason": "Debugging an account issue.", + } + assert request_kwargs["json"] == { + **params, + **base_authentication_params, + "code_verifier": None, + "ip_address": None, + "user_agent": None, + "grant_type": "authorization_code", + } def test_authenticate_with_magic_auth( - self, capture_and_mock_request, mock_auth_response + self, + capture_and_mock_http_client_request, + mock_auth_response, + base_authentication_params, ): - code = "test_auth" - email = "marcelina@foo-corp.com" - user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" - ip_address = "192.0.0.1" + params = { + "code": "test_auth", + "email": "marcelina@foo-corp.com", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36", + "ip_address": "192.0.0.1", + } - request_args, request = capture_and_mock_request( - "post", mock_auth_response, 200 + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_auth_response, 200 ) - response = self.user_management.authenticate_with_magic_auth( - code=code, - email=email, - user_agent=user_agent, - ip_address=ip_address, - ) + response = self.user_management.authenticate_with_magic_auth(**params) - assert request_args[1].endswith("user_management/authenticate") - assert response["user"]["id"] == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" - assert response["organization_id"] == "org_12345" - assert response["access_token"] == "access_token_12345" - assert response["refresh_token"] == "refresh_token_12345" - assert request["json"]["code"] == code - assert request["json"]["user_agent"] == user_agent - assert request["json"]["email"] == email - assert request["json"]["ip_address"] == ip_address - assert request["json"]["client_id"] == "client_b27needthisforssotemxo" - assert request["json"]["client_secret"] == "sk_test" - assert ( - request["json"]["grant_type"] - == "urn:workos:oauth:grant-type:magic-auth:code" - ) + assert request_kwargs["url"].endswith("user_management/authenticate") + assert response.user.id == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" + assert response.organization_id == "org_12345" + assert response.access_token == "access_token_12345" + assert response.refresh_token == "refresh_token_12345" + assert request_kwargs["json"] == { + **params, + **base_authentication_params, + "grant_type": "urn:workos:oauth:grant-type:magic-auth:code", + "link_authorization_code": None, + } def test_authenticate_with_email_verification( - self, capture_and_mock_request, mock_auth_response + self, + capture_and_mock_http_client_request, + mock_auth_response, + base_authentication_params, ): - code = "test_auth" - pending_authentication_token = "ql1AJgNoLN1tb9llaQ8jyC2dn" - user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" - ip_address = "192.0.0.1" - - request_args, request = capture_and_mock_request( - "post", mock_auth_response, 200 - ) - - response = self.user_management.authenticate_with_email_verification( - code=code, - pending_authentication_token=pending_authentication_token, - user_agent=user_agent, - ip_address=ip_address, - ) - - assert request_args[1].endswith("user_management/authenticate") - assert response["user"]["id"] == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" - assert response["organization_id"] == "org_12345" - assert response["access_token"] == "access_token_12345" - assert response["refresh_token"] == "refresh_token_12345" - assert request["json"]["code"] == code - assert request["json"]["user_agent"] == user_agent - assert ( - request["json"]["pending_authentication_token"] - == pending_authentication_token - ) - assert request["json"]["ip_address"] == ip_address - assert request["json"]["client_id"] == "client_b27needthisforssotemxo" - assert request["json"]["client_secret"] == "sk_test" - assert ( - request["json"]["grant_type"] - == "urn:workos:oauth:grant-type:email-verification:code" - ) - - def test_authenticate_with_totp(self, capture_and_mock_request, mock_auth_response): - code = "test_auth" - authentication_challenge_id = "auth_challenge_01FVYZWQTZQ5VB6BC5MPG2EYC5" - pending_authentication_token = "ql1AJgNoLN1tb9llaQ8jyC2dn" - user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" - ip_address = "192.0.0.1" - - request_args, request = capture_and_mock_request( - "post", mock_auth_response, 200 - ) - - response = self.user_management.authenticate_with_totp( - code=code, - authentication_challenge_id=authentication_challenge_id, - pending_authentication_token=pending_authentication_token, - user_agent=user_agent, - ip_address=ip_address, - ) - - assert request_args[1].endswith("user_management/authenticate") - assert response["user"]["id"] == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" - assert response["organization_id"] == "org_12345" - assert response["access_token"] == "access_token_12345" - assert response["refresh_token"] == "refresh_token_12345" - assert request["json"]["code"] == code - assert request["json"]["user_agent"] == user_agent - assert ( - request["json"]["authentication_challenge_id"] - == authentication_challenge_id - ) - assert ( - request["json"]["pending_authentication_token"] - == pending_authentication_token - ) - assert request["json"]["ip_address"] == ip_address - assert request["json"]["client_id"] == "client_b27needthisforssotemxo" - assert request["json"]["client_secret"] == "sk_test" - assert request["json"]["grant_type"] == "urn:workos:oauth:grant-type:mfa-totp" + params = { + "code": "test_auth", + "pending_authentication_token": "ql1AJgNoLN1tb9llaQ8jyC2dn", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36", + "ip_address": "192.0.0.1", + } - def test_authenticate_with_organization_selection( - self, capture_and_mock_request, mock_auth_response + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_auth_response, 200 + ) + + response = self.user_management.authenticate_with_email_verification(**params) + + assert request_kwargs["url"].endswith("user_management/authenticate") + assert response.user.id == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" + assert response.organization_id == "org_12345" + assert response.access_token == "access_token_12345" + assert response.refresh_token == "refresh_token_12345" + assert request_kwargs["json"] == { + **params, + **base_authentication_params, + "grant_type": "urn:workos:oauth:grant-type:email-verification:code", + } + + def test_authenticate_with_totp( + self, + capture_and_mock_http_client_request, + mock_auth_response, + base_authentication_params, ): - organization_id = "org_12345" - pending_authentication_token = "ql1AJgNoLN1tb9llaQ8jyC2dn" - user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" - ip_address = "192.0.0.1" + params = { + "code": "test_auth", + "authentication_challenge_id": "auth_challenge_01FVYZWQTZQ5VB6BC5MPG2EYC5", + "pending_authentication_token": "ql1AJgNoLN1tb9llaQ8jyC2dn", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36", + "ip_address": "192.0.0.1", + } + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_auth_response, 200 + ) + + response = self.user_management.authenticate_with_totp(**params) + + assert request_kwargs["url"].endswith("user_management/authenticate") + assert response.user.id == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" + assert response.organization_id == "org_12345" + assert response.access_token == "access_token_12345" + assert response.refresh_token == "refresh_token_12345" + assert request_kwargs["json"] == { + **params, + **base_authentication_params, + "grant_type": "urn:workos:oauth:grant-type:mfa-totp", + } - request_args, request = capture_and_mock_request( - "post", mock_auth_response, 200 + def test_authenticate_with_organization_selection( + self, + capture_and_mock_http_client_request, + mock_auth_response, + base_authentication_params, + ): + params = { + "organization_id": "org_12345", + "pending_authentication_token": "ql1AJgNoLN1tb9llaQ8jyC2dn", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36", + "ip_address": "192.0.0.1", + } + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_auth_response, 200 ) response = self.user_management.authenticate_with_organization_selection( - organization_id=organization_id, - pending_authentication_token=pending_authentication_token, - user_agent=user_agent, - ip_address=ip_address, - ) - - assert request_args[1].endswith("user_management/authenticate") - assert response["user"]["id"] == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" - assert response["organization_id"] == "org_12345" - assert response["access_token"] == "access_token_12345" - assert response["refresh_token"] == "refresh_token_12345" - assert request["json"]["organization_id"] == organization_id - assert request["json"]["user_agent"] == user_agent - assert ( - request["json"]["pending_authentication_token"] - == pending_authentication_token - ) - assert request["json"]["ip_address"] == ip_address - assert request["json"]["client_id"] == "client_b27needthisforssotemxo" - assert request["json"]["client_secret"] == "sk_test" - assert ( - request["json"]["grant_type"] - == "urn:workos:oauth:grant-type:organization-selection" - ) + **params + ) + + assert request_kwargs["url"].endswith("user_management/authenticate") + assert response.user.id == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" + assert response.organization_id == "org_12345" + assert response.access_token == "access_token_12345" + assert response.refresh_token == "refresh_token_12345" + assert request_kwargs["json"] == { + **params, + **base_authentication_params, + "grant_type": "urn:workos:oauth:grant-type:organization-selection", + } def test_authenticate_with_refresh_token( - self, capture_and_mock_request, mock_auth_refresh_token_response + self, + capture_and_mock_http_client_request, + mock_auth_refresh_token_response, + base_authentication_params, ): - refresh_token = "refresh_token_98765" - user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" - ip_address = "192.0.0.1" - - request_args, request = capture_and_mock_request( - "post", mock_auth_refresh_token_response, 200 + params = { + "refresh_token": "refresh_token_98765", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36", + "ip_address": "192.0.0.1", + } + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_auth_refresh_token_response, 200 ) - response = self.user_management.authenticate_with_refresh_token( - refresh_token=refresh_token, - user_agent=user_agent, - ip_address=ip_address, - ) + response = self.user_management.authenticate_with_refresh_token(**params) - assert request_args[1].endswith("user_management/authenticate") - assert response["access_token"] == "access_token_12345" - assert response["refresh_token"] == "refresh_token_12345" - assert request["json"]["user_agent"] == user_agent - assert request["json"]["refresh_token"] == refresh_token - assert request["json"]["ip_address"] == ip_address - assert request["json"]["client_id"] == "client_b27needthisforssotemxo" - assert request["json"]["client_secret"] == "sk_test" - assert request["json"]["grant_type"] == "refresh_token" + assert request_kwargs["url"].endswith("user_management/authenticate") + assert response.access_token == "access_token_12345" + assert response.refresh_token == "refresh_token_12345" + assert request_kwargs["json"] == { + **params, + **base_authentication_params, + "grant_type": "refresh_token", + } def test_get_jwks_url(self): expected = "%ssso/jwks/%s" % (workos.base_api_url, workos.client_id) @@ -923,145 +710,125 @@ def test_get_logout_url(self): assert expected == result - def test_get_password_reset(self, mock_password_reset, capture_and_mock_request): - request_args, request_kwargs = capture_and_mock_request( - "get", mock_password_reset, 200 + def test_get_password_reset( + self, mock_password_reset, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_password_reset, 200 ) password_reset = self.user_management.get_password_reset("password_reset_ABCDE") - assert request_args[1].endswith( + assert request_kwargs["url"].endswith( "user_management/password_reset/password_reset_ABCDE" ) - assert password_reset["id"] == "password_reset_ABCDE" + assert password_reset.id == "password_reset_ABCDE" - def test_create_password_reset(self, capture_and_mock_request, mock_password_reset): + def test_create_password_reset( + self, capture_and_mock_http_client_request, mock_password_reset + ): email = "marcelina@foo-corp.com" - request_args, _ = capture_and_mock_request("post", mock_password_reset, 201) + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_password_reset, 201 + ) password_reset = self.user_management.create_password_reset(email=email) - assert request_args[1].endswith("user_management/password_reset") - assert password_reset["email"] == email + assert request_kwargs["url"].endswith("user_management/password_reset") + assert password_reset.email == email - def test_send_password_reset_email(self, capture_and_mock_request): - email = "marcelina@foo-corp.com" - password_reset_url = "https://foo-corp.com/reset-password" - - request_args, request = capture_and_mock_request("post", None, 200) - - with pytest.warns( - DeprecationWarning, - match="'send_password_reset_email' is deprecated. Please use 'create_password_reset' instead. This method will be removed in a future major version.", - ): - response = self.user_management.send_password_reset_email( - email=email, - password_reset_url=password_reset_url, - ) - - assert request_args[1].endswith("user_management/password_reset/send") - assert request["json"]["email"] == email - assert request["json"]["password_reset_url"] == password_reset_url - assert response is None - - def test_reset_password(self, capture_and_mock_request, mock_user): - token = "token123" - new_password = "pass123" - - request_args, request = capture_and_mock_request( - "post", {"user": mock_user}, 200 + def test_reset_password(self, capture_and_mock_http_client_request, mock_user): + params = { + "token": "token123", + "new_password": "pass123", + } + request_kwargs = capture_and_mock_http_client_request( + self.http_client, {"user": mock_user}, 200 ) - response = self.user_management.reset_password( - token=token, - new_password=new_password, - ) + response = self.user_management.reset_password(**params) - assert request_args[1].endswith("user_management/password_reset/confirm") - assert response["id"] == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" - assert request["json"]["token"] == token - assert request["json"]["new_password"] == new_password + assert request_kwargs["url"].endswith("user_management/password_reset/confirm") + assert response.id == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" + assert request_kwargs["json"] == params def test_get_email_verification( - self, mock_email_verification, capture_and_mock_request + self, mock_email_verification, capture_and_mock_http_client_request ): - request_args, request_kwargs = capture_and_mock_request( - "get", mock_email_verification, 200 + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_email_verification, 200 ) email_verification = self.user_management.get_email_verification( "email_verification_ABCDE" ) - assert request_args[1].endswith( + assert request_kwargs["url"].endswith( "user_management/email_verification/email_verification_ABCDE" ) - assert email_verification["id"] == "email_verification_ABCDE" + assert email_verification.id == "email_verification_ABCDE" - def test_send_verification_email(self, capture_and_mock_request, mock_user): + def test_send_verification_email( + self, capture_and_mock_http_client_request, mock_user + ): user_id = "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" - request_args, _ = capture_and_mock_request("post", {"user": mock_user}, 200) + request_kwargs = capture_and_mock_http_client_request( + self.http_client, {"user": mock_user}, 200 + ) response = self.user_management.send_verification_email(user_id=user_id) - assert request_args[1].endswith( + assert request_kwargs["url"].endswith( "user_management/users/user_01H7ZGXFP5C6BBQY6Z7277ZCT0/email_verification/send" ) - assert response["id"] == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" + assert response.id == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" - def test_verify_email(self, capture_and_mock_request, mock_user): + def test_verify_email(self, capture_and_mock_http_client_request, mock_user): user_id = "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" code = "code_123" - request_args, request = capture_and_mock_request( - "post", {"user": mock_user}, 200 + request_kwargs = capture_and_mock_http_client_request( + self.http_client, {"user": mock_user}, 200 ) response = self.user_management.verify_email(user_id=user_id, code=code) - assert request_args[1].endswith( + assert request_kwargs["url"].endswith( "user_management/users/user_01H7ZGXFP5C6BBQY6Z7277ZCT0/email_verification/confirm" ) - assert request["json"]["code"] == code - assert response["id"] == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" + assert request_kwargs["json"]["code"] == code + assert response.id == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" - def test_get_magic_auth(self, mock_magic_auth, capture_and_mock_request): - request_args, request_kwargs = capture_and_mock_request( - "get", mock_magic_auth, 200 + def test_get_magic_auth( + self, mock_magic_auth, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_magic_auth, 200 ) magic_auth = self.user_management.get_magic_auth("magic_auth_ABCDE") - assert request_args[1].endswith("user_management/magic_auth/magic_auth_ABCDE") - assert magic_auth["id"] == "magic_auth_ABCDE" + assert request_kwargs["url"].endswith( + "user_management/magic_auth/magic_auth_ABCDE" + ) + assert magic_auth.id == "magic_auth_ABCDE" - def test_create_magic_auth(self, capture_and_mock_request, mock_magic_auth): + def test_create_magic_auth( + self, capture_and_mock_http_client_request, mock_magic_auth + ): email = "marcelina@foo-corp.com" - request_args, _ = capture_and_mock_request("post", mock_magic_auth, 201) + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_magic_auth, 201 + ) magic_auth = self.user_management.create_magic_auth(email=email) - assert request_args[1].endswith("user_management/magic_auth") - assert magic_auth["email"] == email - - def test_send_magic_auth_code(self, capture_and_mock_request): - email = "marcelina@foo-corp.com" - - request_args, request = capture_and_mock_request("post", None, 200) - - with pytest.warns( - DeprecationWarning, - match="'send_magic_auth_code' is deprecated. Please use 'create_magic_auth' instead. This method will be removed in a future major version.", - ): - response = self.user_management.send_magic_auth_code(email=email) - - assert request_args[1].endswith("user_management/magic_auth/send") - assert request["json"]["email"] == email - assert response is None + assert request_kwargs["url"].endswith("user_management/magic_auth") + assert magic_auth.email == email def test_enroll_auth_factor( - self, mock_enroll_auth_factor_response, mock_request_method + self, mock_enroll_auth_factor_response, mock_http_client_with_response ): user_id = "user_01H7ZGXFP5C6BBQY6Z7277ZCT0" type = "totp" @@ -1069,7 +836,9 @@ def test_enroll_auth_factor( totp_user = "marcelina@foo-corp.com" totp_secret = "secret-test" - mock_request_method("post", mock_enroll_auth_factor_response, 200) + mock_http_client_with_response( + self.http_client, mock_enroll_auth_factor_response, 200 + ) enroll_auth_factor = self.user_management.enroll_auth_factor( user_id=user_id, @@ -1079,78 +848,84 @@ def test_enroll_auth_factor( totp_secret=totp_secret, ) - assert enroll_auth_factor == mock_enroll_auth_factor_response + assert enroll_auth_factor.dict() == mock_enroll_auth_factor_response - def test_auth_factors_returns_metadata( - self, - mock_auth_factors, - mock_request_method, + def test_list_auth_factors_auto_pagination( + self, mock_auth_factors_multiple_pages, test_sync_auto_pagination ): - mock_request_method("get", mock_auth_factors, 200) - - auth_factors = self.user_management.list_auth_factors( - user_id="user_12345", + test_sync_auto_pagination( + http_client=self.http_client, + list_function=self.user_management.list_auth_factors, + list_function_params={"user_id": "user_12345"}, + expected_all_page_data=mock_auth_factors_multiple_pages["data"], ) - dict_auth_factors = auth_factors.to_dict() - assert dict_auth_factors["metadata"]["params"]["user_id"] == "user_12345" - - def test_get_invitation(self, mock_invitation, capture_and_mock_request): - request_args, request_kwargs = capture_and_mock_request( - "get", mock_invitation, 200 + def test_get_invitation( + self, mock_invitation, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_invitation, 200 ) invitation = self.user_management.get_invitation("invitation_ABCDE") - assert request_args[1].endswith("user_management/invitations/invitation_ABCDE") - assert invitation["id"] == "invitation_ABCDE" + assert request_kwargs["url"].endswith( + "user_management/invitations/invitation_ABCDE" + ) + assert invitation.id == "invitation_ABCDE" - def test_find_invitation_by_token(self, mock_invitation, capture_and_mock_request): - request_args, request_kwargs = capture_and_mock_request( - "get", mock_invitation, 200 + def test_find_invitation_by_token( + self, mock_invitation, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_invitation, 200 ) invitation = self.user_management.find_invitation_by_token( "Z1uX3RbwcIl5fIGJJJCXXisdI" ) - assert request_args[1].endswith( + assert request_kwargs["url"].endswith( "user_management/invitations/by_token/Z1uX3RbwcIl5fIGJJJCXXisdI" ) - assert invitation["token"] == "Z1uX3RbwcIl5fIGJJJCXXisdI" + assert invitation.token == "Z1uX3RbwcIl5fIGJJJCXXisdI" - def test_list_invitations_returns_metadata( - self, - mock_invitations, - mock_request_method, + def test_list_invitations_auto_pagination( + self, mock_invitations_multiple_pages, test_sync_auto_pagination ): - mock_request_method("get", mock_invitations, 200) - - invitations = self.user_management.list_invitations( - organization_id="org_12345", + test_sync_auto_pagination( + http_client=self.http_client, + list_function=self.user_management.list_invitations, + list_function_params={"organization_id": "org_12345"}, + expected_all_page_data=mock_invitations_multiple_pages["data"], ) - dict_invitations = invitations.to_dict() - assert dict_invitations["metadata"]["params"]["organization_id"] == "org_12345" - - def test_send_invitation(self, capture_and_mock_request, mock_invitation): + def test_send_invitation( + self, capture_and_mock_http_client_request, mock_invitation + ): email = "marcelina@foo-corp.com" organization_id = "org_12345" - request_args, _ = capture_and_mock_request("post", mock_invitation, 201) + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_invitation, 201 + ) invitation = self.user_management.send_invitation( email=email, organization_id=organization_id ) - assert request_args[1].endswith("user_management/invitations") - assert invitation["email"] == email - assert invitation["organization_id"] == organization_id + assert request_kwargs["url"].endswith("user_management/invitations") + assert invitation.email == email + assert invitation.organization_id == organization_id - def test_revoke_invitation(self, capture_and_mock_request, mock_invitation): - request_args, _ = capture_and_mock_request("post", mock_invitation, 200) + def test_revoke_invitation( + self, capture_and_mock_http_client_request, mock_invitation + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_invitation, 200 + ) self.user_management.revoke_invitation("invitation_ABCDE") - assert request_args[1].endswith( + assert request_kwargs["url"].endswith( "user_management/invitations/invitation_ABCDE/revoke" ) diff --git a/tests/utils/fixtures/mock_auth_factor_totp.py b/tests/utils/fixtures/mock_auth_factor_totp.py index 7bad594a..9d218bac 100644 --- a/tests/utils/fixtures/mock_auth_factor_totp.py +++ b/tests/utils/fixtures/mock_auth_factor_totp.py @@ -4,12 +4,16 @@ class MockAuthFactorTotp(WorkOSBaseResource): def __init__(self, id): + now = datetime.datetime.now().isoformat() self.object = "authentication_factor" self.id = id - self.created_at = datetime.datetime.now() - self.updated_at = datetime.datetime.now() + self.created_at = now + self.updated_at = now self.type = "totp" + self.user_id = "user_123" self.totp = { + "issuer": "FooCorp", + "user": "test@example.com", "qr_code": "data:image/png;base64,{base64EncodedPng}", "secret": "NAGCCFS3EYRB422HNAKAKY3XDUORMSRF", "uri": "otpauth://totp/FooCorp:alan.turing@foo-corp.com?secret=NAGCCFS3EYRB422HNAKAKY3XDUORMSRF&issuer=FooCorp", @@ -22,4 +26,5 @@ def __init__(self, id): "updated_at", "type", "totp", + "user_id", ] diff --git a/tests/utils/fixtures/mock_connection.py b/tests/utils/fixtures/mock_connection.py index 1bf7352a..5f742693 100644 --- a/tests/utils/fixtures/mock_connection.py +++ b/tests/utils/fixtures/mock_connection.py @@ -4,14 +4,15 @@ class MockConnection(WorkOSBaseResource): def __init__(self, id): + now = datetime.datetime.now().isoformat() self.object = "connection" self.id = id self.organization_id = "org_id_" + id self.connection_type = "OktaSAML" self.name = "Foo Corporation" self.state = "active" - self.created_at = datetime.datetime.now().isoformat() - self.updated_at = datetime.datetime.now().isoformat() + self.created_at = now + self.updated_at = now self.domains = [ { "id": "connection_domain_abc123", diff --git a/tests/utils/fixtures/mock_email_verification.py b/tests/utils/fixtures/mock_email_verification.py index 35b68e09..73f5f3b7 100644 --- a/tests/utils/fixtures/mock_email_verification.py +++ b/tests/utils/fixtures/mock_email_verification.py @@ -4,15 +4,18 @@ class MockEmailVerification(WorkOSBaseResource): def __init__(self, id): + now = datetime.datetime.now().isoformat() + self.object = "email_verification" self.id = id self.user_id = "user_01HWZBQAY251RZ9BKB4RZW4D4A" self.email = "marcelina@foo-corp.com" - self.expires_at = datetime.datetime.now() + self.expires_at = now self.code = "123456" - self.created_at = datetime.datetime.now() - self.updated_at = datetime.datetime.now() + self.created_at = now + self.updated_at = now OBJECT_FIELDS = [ + "object", "id", "user_id", "email", diff --git a/tests/utils/fixtures/mock_invitation.py b/tests/utils/fixtures/mock_invitation.py index 7e24533e..ae823555 100644 --- a/tests/utils/fixtures/mock_invitation.py +++ b/tests/utils/fixtures/mock_invitation.py @@ -4,22 +4,25 @@ class MockInvitation(WorkOSBaseResource): def __init__(self, id): + now = datetime.datetime.now().isoformat() + self.object = "invitation" self.id = id self.email = "marcelina@foo-corp.com" self.state = "pending" self.accepted_at = None self.revoked_at = None - self.expires_at = datetime.datetime.now() + self.expires_at = now self.token = "Z1uX3RbwcIl5fIGJJJCXXisdI" self.accept_invitation_url = ( "https://your-app.com/invite?invitation_token=Z1uX3RbwcIl5fIGJJJCXXisdI" ) self.organization_id = "org_12345" self.inviter_user_id = "user_123" - self.created_at = datetime.datetime.now() - self.updated_at = datetime.datetime.now() + self.created_at = now + self.updated_at = now OBJECT_FIELDS = [ + "object", "id", "email", "state", diff --git a/tests/utils/fixtures/mock_magic_auth.py b/tests/utils/fixtures/mock_magic_auth.py index adf3d22a..fd51c951 100644 --- a/tests/utils/fixtures/mock_magic_auth.py +++ b/tests/utils/fixtures/mock_magic_auth.py @@ -4,15 +4,18 @@ class MockMagicAuth(WorkOSBaseResource): def __init__(self, id): + now = datetime.datetime.now().isoformat() + self.object = "magic_auth" self.id = id self.user_id = "user_01HWZBQAY251RZ9BKB4RZW4D4A" self.email = "marcelina@foo-corp.com" - self.expires_at = datetime.datetime.now() + self.expires_at = now self.code = "123456" - self.created_at = datetime.datetime.now() - self.updated_at = datetime.datetime.now() + self.created_at = now + self.updated_at = now OBJECT_FIELDS = [ + "object", "id", "user_id", "email", diff --git a/tests/utils/fixtures/mock_organization.py b/tests/utils/fixtures/mock_organization.py index b4ebe3ed..8388dc27 100644 --- a/tests/utils/fixtures/mock_organization.py +++ b/tests/utils/fixtures/mock_organization.py @@ -4,12 +4,13 @@ class MockOrganization(WorkOSBaseResource): def __init__(self, id): + now = datetime.datetime.now().isoformat() self.id = id self.object = "organization" self.name = "Foo Corporation" self.allow_profiles_outside_organization = False - self.created_at = datetime.datetime.now().isoformat() - self.updated_at = datetime.datetime.now().isoformat() + self.created_at = now + self.updated_at = now self.domains = [ { "domain": "example.io", diff --git a/tests/utils/fixtures/mock_organization_membership.py b/tests/utils/fixtures/mock_organization_membership.py index 8ef61f58..83937cbf 100644 --- a/tests/utils/fixtures/mock_organization_membership.py +++ b/tests/utils/fixtures/mock_organization_membership.py @@ -4,15 +4,18 @@ class MockOrganizationMembership(WorkOSBaseResource): def __init__(self, id): + now = datetime.datetime.now().isoformat() + self.object = "organization_membership" self.id = id self.user_id = "user_12345" self.organization_id = "org_67890" self.status = "active" self.role = {"slug": "member"} - self.created_at = datetime.datetime.now() - self.updated_at = datetime.datetime.now() + self.created_at = now + self.updated_at = now OBJECT_FIELDS = [ + "object", "id", "user_id", "organization_id", diff --git a/tests/utils/fixtures/mock_password_reset.py b/tests/utils/fixtures/mock_password_reset.py index 12280966..ac05d0c6 100644 --- a/tests/utils/fixtures/mock_password_reset.py +++ b/tests/utils/fixtures/mock_password_reset.py @@ -4,6 +4,8 @@ class MockPasswordReset(WorkOSBaseResource): def __init__(self, id): + now = datetime.datetime.now().isoformat() + self.object = "password_reset" self.id = id self.user_id = "user_01HWZBQAY251RZ9BKB4RZW4D4A" self.email = "marcelina@foo-corp.com" @@ -11,10 +13,11 @@ def __init__(self, id): self.password_reset_url = ( "https://your-app.com/reset-password?token=Z1uX3RbwcIl5fIGJJJCXXisdI" ) - self.expires_at = datetime.datetime.now() - self.created_at = datetime.datetime.now() + self.expires_at = now + self.created_at = now OBJECT_FIELDS = [ + "object", "id", "user_id", "email", diff --git a/tests/utils/fixtures/mock_user.py b/tests/utils/fixtures/mock_user.py index 59dd033c..a32f5f52 100644 --- a/tests/utils/fixtures/mock_user.py +++ b/tests/utils/fixtures/mock_user.py @@ -4,23 +4,25 @@ class MockUser(WorkOSBaseResource): def __init__(self, id): + now = datetime.datetime.now().isoformat() + self.object = "user" self.id = id self.email = "marcelina@foo-corp.com" self.first_name = "Marcelina" self.last_name = "Hoeger" - self.email_verified_at = "" + self.email_verified = False self.profile_picture_url = "https://example.com/profile-picture.jpg" - self.created_at = datetime.datetime.now() - self.updated_at = datetime.datetime.now() + self.created_at = now + self.updated_at = now OBJECT_FIELDS = [ + "object", "id", "email", "first_name", "last_name", - "sso_profile_id", "profile_picture_url", - "email_verified_at", + "email_verified", "created_at", "updated_at", ] diff --git a/workos/client.py b/workos/client.py index 0fa77d5c..9c4e7405 100644 --- a/workos/client.py +++ b/workos/client.py @@ -79,5 +79,5 @@ def mfa(self): @property def user_management(self): if not getattr(self, "_user_management", None): - self._user_management = UserManagement() + self._user_management = UserManagement(self._http_client) return self._user_management diff --git a/workos/directory_sync.py b/workos/directory_sync.py index 8671c2b6..3d478769 100644 --- a/workos/directory_sync.py +++ b/workos/directory_sync.py @@ -97,7 +97,7 @@ def list_users( self, directory: Optional[str] = None, group: Optional[str] = None, - limit: Optional[int] = DEFAULT_LIST_RESPONSE_LIMIT, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", @@ -121,7 +121,7 @@ def list_users( """ list_params: DirectoryUserListFilters = { - "limit": limit if limit is not None else DEFAULT_LIST_RESPONSE_LIMIT, + "limit": limit, "before": before, "after": after, "order": order, @@ -188,7 +188,9 @@ def list_groups( token=workos.api_key, ) - return WorkOsListResource( + return WorkOsListResource[ + DirectoryGroup, DirectoryGroupListFilters, ListMetadata + ]( list_method=self.list_groups, list_args=list_params, **ListPage[DirectoryGroup](**response).model_dump(), @@ -253,7 +255,7 @@ def list_directories( limit: int = DEFAULT_LIST_RESPONSE_LIMIT, before: Optional[str] = None, after: Optional[str] = None, - organization: Optional[str] = None, + organization_id: Optional[str] = None, order: PaginationOrder = "desc", ) -> WorkOsListResource[Directory, DirectoryListFilters, ListMetadata]: """Gets details for existing Directories. @@ -277,7 +279,7 @@ def list_directories( "after": after, "order": order, "domain": domain, - "organization_id": organization, + "organization_id": organization_id, "search": search, } @@ -287,7 +289,7 @@ def list_directories( params=list_params, token=workos.api_key, ) - return WorkOsListResource( + return WorkOsListResource[Directory, DirectoryListFilters, ListMetadata]( list_method=self.list_directories, list_args=list_params, **ListPage[Directory](**response).model_dump(), @@ -345,7 +347,7 @@ async def list_users( dict: Directory Users response from WorkOS. """ - list_params = { + list_params: DirectoryUserListFilters = { "limit": limit, "before": before, "after": after, @@ -396,7 +398,7 @@ async def list_groups( Returns: dict: Directory Groups response from WorkOS. """ - list_params = { + list_params: DirectoryGroupListFilters = { "limit": limit, "before": before, "after": after, @@ -414,7 +416,9 @@ async def list_groups( token=workos.api_key, ) - return AsyncWorkOsListResource( + return AsyncWorkOsListResource[ + DirectoryGroup, DirectoryGroupListFilters, ListMetadata + ]( list_method=self.list_groups, list_args=list_params, **ListPage[DirectoryGroup](**response).model_dump(), @@ -479,7 +483,7 @@ async def list_directories( limit: int = DEFAULT_LIST_RESPONSE_LIMIT, before: Optional[str] = None, after: Optional[str] = None, - organization: Optional[str] = None, + organization_id: Optional[str] = None, order: PaginationOrder = "desc", ) -> AsyncWorkOsListResource[Directory, DirectoryListFilters, ListMetadata]: """Gets details for existing Directories. @@ -497,9 +501,9 @@ async def list_directories( dict: Directories response from WorkOS. """ - list_params = { + list_params: DirectoryListFilters = { "domain": domain, - "organization": organization, + "organization_id": organization_id, "search": search, "limit": limit, "before": before, @@ -513,7 +517,7 @@ async def list_directories( params=list_params, token=workos.api_key, ) - return AsyncWorkOsListResource( + return AsyncWorkOsListResource[Directory, DirectoryListFilters, ListMetadata]( list_method=self.list_directories, list_args=list_params, **ListPage[Directory](**response).model_dump(), diff --git a/workos/events.py b/workos/events.py index d9375332..df8ead7d 100644 --- a/workos/events.py +++ b/workos/events.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Protocol +from typing import List, Optional, Protocol, Union import workos from workos.typing.sync_or_async import SyncOrAsync @@ -8,6 +8,7 @@ from workos.utils.validation import EVENTS_MODULE, validate_settings from workos.resources.list import ( ListAfterMetadata, + AsyncWorkOsListResource, ListArgs, ListPage, WorkOsListResource, @@ -21,7 +22,10 @@ class EventsListFilters(ListArgs, total=False): range_end: Optional[str] -EventsListResource = WorkOsListResource[Event, EventsListFilters, ListAfterMetadata] +EventsListResource = Union[ + AsyncWorkOsListResource[Event, EventsListFilters, ListAfterMetadata], + WorkOsListResource[Event, EventsListFilters, ListAfterMetadata], +] class EventsModule(Protocol): @@ -83,7 +87,7 @@ def list_events( params=params, token=workos.api_key, ) - return WorkOsListResource( + return WorkOsListResource[Event, EventsListFilters, ListAfterMetadata]( list_method=self.list_events, list_args=params, **ListPage[Event](**response).model_dump(exclude_unset=True), @@ -137,7 +141,7 @@ async def list_events( token=workos.api_key, ) - return WorkOsListResource( + return AsyncWorkOsListResource[Event, EventsListFilters, ListAfterMetadata]( list_method=self.list_events, list_args=params, **ListPage[Event](**response).model_dump(exclude_unset=True), diff --git a/workos/resources/list.py b/workos/resources/list.py index addc7cfb..1bd3ddff 100644 --- a/workos/resources/list.py +++ b/workos/resources/list.py @@ -18,13 +18,14 @@ from workos.resources.directory_sync import ( Directory, DirectoryGroup, - DirectoryUser, DirectoryUserWithGroups, ) from workos.resources.events import Event +from workos.resources.mfa import AuthenticationFactor from workos.resources.organizations import Organization from pydantic import BaseModel, Field from workos.resources.sso import Connection +from workos.resources.user_management import Invitation, OrganizationMembership, User from workos.resources.workos_model import WorkOSModel @@ -122,12 +123,16 @@ def auto_paging_iter(self): ListableResource = TypeVar( # add all possible generics of List Resource "ListableResource", + AuthenticationFactor, Connection, Directory, DirectoryGroup, DirectoryUserWithGroups, Event, + Invitation, Organization, + OrganizationMembership, + User, ) diff --git a/workos/resources/mfa.py b/workos/resources/mfa.py index 241df3f8..f0d0e5d1 100644 --- a/workos/resources/mfa.py +++ b/workos/resources/mfa.py @@ -1,4 +1,34 @@ +from typing import Literal, Optional, Union from workos.resources.base import WorkOSBaseResource +from workos.resources.workos_model import WorkOSModel + + +SmsAuthenticationFactorType = Literal["sms"] +TotpAuthenticationFactorType = Literal["totp"] +AuthenticationFactorType = Literal[ + "generic_otp", SmsAuthenticationFactorType, TotpAuthenticationFactorType +] + + +class TotpFactor(WorkOSModel): + """Representation of a TOTP factor as returned in events.""" + + issuer: str + user: str + + +class ExtendedTotpFactor(TotpFactor): + """Representation of a TOTP factor as returned by the API.""" + + issuer: str + user: str + qr_code: str + secret: str + uri: str + + +class SmsFactor(WorkOSModel): + phone_number: str class WorkOSAuthenticationFactorTotp(WorkOSBaseResource): @@ -31,6 +61,34 @@ def to_dict(self): return challenge_response_dict +class AuthenticationFactorBase(WorkOSModel): + """Representation of a MFA Authentication Factor Response as returned by WorkOS through the MFA feature.""" + + object: Literal["authentication_factor"] + id: str + created_at: str + updated_at: str + type: AuthenticationFactorType + user_id: Optional[str] = None + + +class AuthenticationFactorTotp(AuthenticationFactorBase): + """Representation of a MFA Authentication Factor Response as returned by WorkOS through the MFA feature.""" + + type: TotpAuthenticationFactorType + totp: Union[TotpFactor, ExtendedTotpFactor, None] + + +class AuthenticationFactorSms(AuthenticationFactorBase): + """Representation of a SMS Authentication Factor Response as returned by WorkOS through the MFA feature.""" + + type: SmsAuthenticationFactorType + sms: Union[SmsFactor, None] + + +AuthenticationFactor = Union[AuthenticationFactorTotp, AuthenticationFactorSms] + + class WorkOSAuthenticationFactorSms(WorkOSBaseResource): """Representation of a MFA Authentication Factor Response as returned by WorkOS through the MFA feature. @@ -91,6 +149,25 @@ def to_dict(self): return challenge_response_dict +class AuthenticationChallenge(WorkOSModel): + """Representation of a MFA Challenge Response as returned by WorkOS through the MFA feature.""" + + object: Literal["authentication_challenge"] + id: str + created_at: str + updated_at: str + expires_at: Optional[str] = None + code: Optional[str] = None + authentication_factor_id: str + + +class AuthenticationFactorTotpAndChallengeResponse(WorkOSModel): + """Representation of an authentication factor and authentication challenge response as returned by WorkOS through User Management features.""" + + authentication_factor: AuthenticationFactorTotp + authentication_challenge: AuthenticationChallenge + + class WorkOSChallengeVerification(WorkOSBaseResource): """Representation of a MFA Challenge Verification Response as returned by WorkOS through the MFA feature. diff --git a/workos/resources/organizations.py b/workos/resources/organizations.py index 4752ba46..8bda09a3 100644 --- a/workos/resources/organizations.py +++ b/workos/resources/organizations.py @@ -1,4 +1,5 @@ -from typing import List, Literal, Optional, TypedDict +from typing import Literal, Optional +from typing_extensions import TypedDict from workos.resources.workos_model import WorkOSModel diff --git a/workos/resources/user_management.py b/workos/resources/user_management.py index 1d4d15f8..5f61253c 100644 --- a/workos/resources/user_management.py +++ b/workos/resources/user_management.py @@ -1,232 +1,137 @@ -from workos.resources.base import WorkOSBaseResource +from typing import Literal, Optional +from typing_extensions import TypedDict +from workos.resources.workos_model import WorkOSModel -class WorkOSAuthenticationResponse(WorkOSBaseResource): - """Representation of a User and Organization ID response as returned by WorkOS through User Management features.""" - """Attributes: - OBJECT_FIELDS (list): List of fields a WorkOSAuthenticationResponse comprises. - """ +PasswordHashType = Literal["bcrypt", "firebase-scrypt", "ssha"] - OBJECT_FIELDS = [ - "access_token", - "organization_id", - "refresh_token", - ] - - @classmethod - def construct_from_response(cls, response): - authentication_response = super( - WorkOSAuthenticationResponse, cls - ).construct_from_response(response) +AuthenticationMethod = Literal[ + "SSO", + "Password", + "AppleOAuth", + "GitHubOAuth", + "GoogleOAuth", + "MicrosoftOAuth", + "MagicAuth", + "Impersonation", +] - user = WorkOSUser.construct_from_response(response["user"]) - authentication_response.user = user - if "impersonator" in response: - impersonator = WorkOSImpersonator.construct_from_response( - response["impersonator"] - ) - authentication_response.impersonator = impersonator - else: - authentication_response.impersonator = None - - return authentication_response +class User(WorkOSModel): + """Representation of a WorkOS User.""" - def to_dict(self): - authentication_response_dict = super( - WorkOSAuthenticationResponse, self - ).to_dict() + object: Literal["user"] + id: str + email: str + first_name: Optional[str] = None + last_name: Optional[str] = None + email_verified: bool + profile_picture_url: Optional[str] = None + created_at: str + updated_at: str - user_dict = self.user.to_dict() - authentication_response_dict["user"] = user_dict - if self.impersonator: - authentication_response_dict["impersonator"] = self.impersonator.to_dict() +class Impersonator(WorkOSModel): + """Representation of a WorkOS Dashboard member impersonating a user""" - return authentication_response_dict - - -class WorkOSRefreshTokenAuthenticationResponse(WorkOSBaseResource): - """Representation of refresh token authentication response as returned by WorkOS through User Management features.""" - - """Attributes: - OBJECT_FIELDS (list): List of fields a WorkOSRefreshTokenAuthenticationResponse comprises. - """ - - OBJECT_FIELDS = [ - "access_token", - "refresh_token", - ] + email: str + reason: str - @classmethod - def construct_from_response(cls, response): - authentication_response = super( - WorkOSRefreshTokenAuthenticationResponse, cls - ).construct_from_response(response) - - return authentication_response - - def to_dict(self): - authentication_response_dict = super( - WorkOSRefreshTokenAuthenticationResponse, self - ).to_dict() - - return authentication_response_dict - - -class WorkOSEmailVerification(WorkOSBaseResource): - """Representation of a EmailVerification object as returned by WorkOS through User Management features. - - Attributes: - OBJECT_FIELDS (list): List of fields a WorkOSEmailVerification comprises. - """ - - OBJECT_FIELDS = [ - "id", - "user_id", - "email", - "expires_at", - "code", - "created_at", - "updated_at", - ] - - -class WorkOSInvitation(WorkOSBaseResource): - """Representation of an Invitation as returned by WorkOS through User Management features. - - Attributes: - OBJECT_FIELDS (list): List of fields a WorkOSInvitation comprises. - """ - OBJECT_FIELDS = [ - "id", - "email", - "state", - "accepted_at", - "revoked_at", - "expires_at", - "token", - "accept_invitation_url", - "organization_id", - "inviter_user_id", - "created_at", - "updated_at", - ] - - -class WorkOSMagicAuth(WorkOSBaseResource): - """Representation of a MagicAuth object as returned by WorkOS through User Management features. +class AuthenticationResponse(WorkOSModel): + """Representation of a WorkOS User and Organization ID response.""" - Attributes: - OBJECT_FIELDS (list): List of fields a WorkOSMagicAuth comprises. - """ + access_token: str + authentication_method: Optional[AuthenticationMethod] = None + impersonator: Optional[Impersonator] = None + organization_id: Optional[str] = None + refresh_token: str + user: User - OBJECT_FIELDS = [ - "id", - "user_id", - "email", - "expires_at", - "code", - "created_at", - "updated_at", - ] +class RefreshTokenAuthenticationResponse(WorkOSModel): + """Representation of a WorkOS refresh token authentication response.""" -class WorkOSPasswordReset(WorkOSBaseResource): - """Representation of a PasswordReset object as returned by WorkOS through User Management features. - - Attributes: - OBJECT_FIELDS (list): List of fields a WorkOSPasswordReset comprises. - """ + access_token: str + refresh_token: str - OBJECT_FIELDS = [ - "id", - "user_id", - "email", - "password_reset_token", - "password_reset_url", - "expires_at", - "created_at", - ] - - -class WorkOSOrganizationMembership(WorkOSBaseResource): - """Representation of an Organization Membership as returned by WorkOS through User Management features. - - Attributes: - OBJECT_FIELDS (list): List of fields a WorkOSOrganizationMembership comprises. - """ - - OBJECT_FIELDS = [ - "id", - "user_id", - "organization_id", - "status", - "created_at", - "updated_at", - "role", - ] - - -class WorkOSPasswordChallengeResponse(WorkOSBaseResource): - """Representation of a User and token response as returned by WorkOS through User Management features. - Attributes: - OBJECT_FIELDS (list): List of fields a WorkOSPasswordChallengeResponse is comprised of. - """ - - OBJECT_FIELDS = [ - "token", - ] - - @classmethod - def construct_from_response(cls, response): - challenge_response = super( - WorkOSPasswordChallengeResponse, cls - ).construct_from_response(response) - - user = WorkOSUser.construct_from_response(response["user"]) - challenge_response.user = user - - return challenge_response - - def to_dict(self): - challenge_response = super(WorkOSPasswordChallengeResponse, self).to_dict() - - user_dict = self.user.to_dict() - challenge_response["user"] = user_dict - - return challenge_response - - -class WorkOSUser(WorkOSBaseResource): - """Representation of a User as returned by WorkOS through User Management features. - - Attributes: - OBJECT_FIELDS (list): List of fields a WorkOSUser comprises. - """ - - OBJECT_FIELDS = [ - "id", - "email", - "first_name", - "last_name", - "email_verified", - "profile_picture_url", - "created_at", - "updated_at", - ] - - -class WorkOSImpersonator(WorkOSBaseResource): - """Representation of a WorkOS Dashboard member impersonating a user - - Attributes: - OBJECT_FIELDS (list): List of fields a WorkOSImpersonator comprises. - """ - - OBJECT_FIELDS = [ - "email", - "reason", - ] + +class EmailVerification(WorkOSModel): + """Representation of a WorkOS EmailVerification object.""" + + object: Literal["email_verification"] + id: str + user_id: str + email: str + expires_at: str + code: str + created_at: str + updated_at: str + + +InvitationState = Literal["accepted", "expired", "pending", "revoked"] + + +class Invitation(WorkOSModel): + """Representation of a WorkOS Invitation as returned.""" + + object: Literal["invitation"] + id: str + email: str + state: InvitationState + accepted_at: Optional[str] = None + revoked_at: Optional[str] = None + expires_at: str + token: str + accept_invitation_url: str + organization_id: Optional[str] = None + inviter_user_id: Optional[str] = None + created_at: str + updated_at: str + + +class MagicAuth(WorkOSModel): + """Representation of a WorkOS MagicAuth object.""" + + object: Literal["magic_auth"] + id: str + user_id: str + email: str + expires_at: str + code: str + created_at: str + updated_at: str + + +class PasswordReset(WorkOSModel): + """Representation of a WorkOS PasswordReset object.""" + + object: Literal["password_reset"] + id: str + user_id: str + email: str + password_reset_token: str + password_reset_url: str + expires_at: str + created_at: str + + +class OrganizationMembershipRole(TypedDict): + slug: str + + +OrganizationMembershipStatus = Literal["active", "inactive", "pending"] + + +class OrganizationMembership(WorkOSModel): + """Representation of an WorkOS Organization Membership.""" + + object: Literal["organization_membership"] + id: str + user_id: str + organization_id: str + role: OrganizationMembershipRole + status: OrganizationMembershipStatus + created_at: str + updated_at: str diff --git a/workos/sso.py b/workos/sso.py index 26df7728..915baf73 100644 --- a/workos/sso.py +++ b/workos/sso.py @@ -96,7 +96,7 @@ def get_authorization_url( params["state"] = state return RequestHelper.build_url_with_query_params( - self._http_client.base_url, **params + base_url=self._http_client.base_url, path=AUTHORIZATION_PATH, **params ) def get_profile(self, accessToken: str) -> SyncOrAsync[Profile]: ... @@ -191,7 +191,7 @@ def list_connections( connection_type: Optional[ConnectionType] = None, domain: Optional[str] = None, organization_id: Optional[str] = None, - limit: Optional[int] = DEFAULT_LIST_RESPONSE_LIMIT, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", @@ -210,14 +210,14 @@ def list_connections( dict: Connections response from WorkOS. """ - params = { + params: ConnectionsListFilters = { "connection_type": connection_type, "domain": domain, "organization_id": organization_id, "limit": limit, "before": before, "after": after, - "order": order or "desc", + "order": order, } response = self._http_client.request( @@ -227,7 +227,7 @@ def list_connections( token=workos.api_key, ) - return WorkOsListResource( + return WorkOsListResource[Connection, ConnectionsListFilters, ListMetadata]( list_method=self.list_connections, list_args=params, **ListPage[Connection](**response).model_dump(), @@ -318,7 +318,7 @@ async def list_connections( connection_type: Optional[ConnectionType] = None, domain: Optional[str] = None, organization_id: Optional[str] = None, - limit: Optional[int] = DEFAULT_LIST_RESPONSE_LIMIT, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", @@ -337,14 +337,14 @@ async def list_connections( dict: Connections response from WorkOS. """ - params = { + params: ConnectionsListFilters = { "connection_type": connection_type, "domain": domain, "organization_id": organization_id, "limit": limit, "before": before, "after": after, - "order": order or "desc", + "order": order, } response = await self._http_client.request( @@ -354,7 +354,9 @@ async def list_connections( token=workos.api_key, ) - return AsyncWorkOsListResource( + return AsyncWorkOsListResource[ + Connection, ConnectionsListFilters, ListMetadata + ]( list_method=self.list_connections, list_args=params, **ListPage[Connection](**response).model_dump(), diff --git a/workos/types/events/directory_payload_with_legacy_fields.py b/workos/types/events/directory_payload_with_legacy_fields.py index 74850dd8..a549b219 100644 --- a/workos/types/events/directory_payload_with_legacy_fields.py +++ b/workos/types/events/directory_payload_with_legacy_fields.py @@ -1,7 +1,6 @@ from typing import List, Literal from workos.resources.workos_model import WorkOSModel from workos.types.events.directory_payload import DirectoryPayload -from workos.typing.literals import LiteralOrUntyped class MinimalOrganizationDomain(WorkOSModel): diff --git a/workos/user_management.py b/workos/user_management.py index 36125f7b..e925e757 100644 --- a/workos/user_management.py +++ b/workos/user_management.py @@ -1,31 +1,42 @@ -from typing import Protocol -from warnings import warn +from typing import Optional, Protocol, Set, Union -from requests import Request import workos -from workos.resources.list import WorkOSListResource -from workos.resources.mfa import WorkOSAuthenticationFactorTotp, WorkOSChallenge +from workos.resources.list import ( + ListArgs, + ListMetadata, + ListPage, + SyncOrAsyncListResource, + WorkOSListResource, + WorkOsListResource, +) +from workos.resources.mfa import ( + AuthenticationFactor, + AuthenticationFactorTotpAndChallengeResponse, + AuthenticationFactorType, +) from workos.resources.user_management import ( - WorkOSAuthenticationResponse, - WorkOSRefreshTokenAuthenticationResponse, - WorkOSEmailVerification, - WorkOSInvitation, - WorkOSMagicAuth, - WorkOSPasswordReset, - WorkOSOrganizationMembership, - WorkOSPasswordChallengeResponse, - WorkOSUser, + AuthenticationResponse, + EmailVerification, + Invitation, + MagicAuth, + OrganizationMembership, + OrganizationMembershipStatus, + PasswordHashType, + PasswordReset, + RefreshTokenAuthenticationResponse, + User, ) -from workos.utils.pagination_order import Order +from workos.utils.http_client import AsyncHTTPClient, SyncHTTPClient +from workos.utils.pagination_order import PaginationOrder from workos.utils.um_provider_types import UserManagementProviderType from workos.utils.request import ( DEFAULT_LIST_RESPONSE_LIMIT, - RequestHelper, RESPONSE_TYPE_CODE, REQUEST_METHOD_POST, REQUEST_METHOD_GET, REQUEST_METHOD_DELETE, REQUEST_METHOD_PUT, + RequestHelper, ) from workos.utils.validation import validate_settings, USER_MANAGEMENT_MODULE @@ -58,45 +69,88 @@ PASSWORD_RESET_DETAIL_PATH = "user_management/password_reset/{0}" +class UsersListFilters(ListArgs, total=False): + email: Optional[str] + organization_id: Optional[str] + + +class InvitationsListFilters(ListArgs, total=False): + email: Optional[str] + organization_id: Optional[str] + + +class OrganizationMembershipsListFilters(ListArgs, total=False): + user_id: Optional[str] + organization_id: Optional[str] + # A set of statuses that's concatenated into a comma-separated string + statuses: Optional[str] + + +class AuthenticationFactorsListFilters(ListArgs, total=False): + user_id: str + + class UserManagementModule(Protocol): - def get_user(self, user_id: str) -> dict: ... + _http_client: Union[SyncHTTPClient, AsyncHTTPClient] + + def get_user(self, user_id: str) -> User: ... def list_users( self, - email=None, - organization_id=None, - limit=None, - before=None, - after=None, - order=None, - ) -> dict: ... - - def create_user(self, user: dict) -> dict: ... - - def update_user(self, user_id: str, payload: dict) -> dict: ... + email: Optional[str] = None, + organization_id: Optional[str] = None, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> SyncOrAsyncListResource: ... + + def create_user( + self, + email: str, + password: Optional[str] = None, + password_hash: Optional[str] = None, + password_hash_type: Optional[PasswordHashType] = None, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + email_verified: Optional[bool] = None, + ) -> User: ... + + def update_user( + self, + user_id: str, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + email_verified: Optional[bool] = None, + password: Optional[str] = None, + password_hash: Optional[str] = None, + password_hash_type: Optional[PasswordHashType] = None, + ) -> User: ... def delete_user(self, user_id: str) -> None: ... def create_organization_membership( - self, user_id: str, organization_id: str, role_slug=None - ) -> dict: ... + self, user_id: str, organization_id: str, role_slug: Optional[str] = None + ) -> OrganizationMembership: ... def update_organization_membership( - self, organization_membership_id: str, role_slug=None - ) -> dict: ... + self, organization_membership_id: str, role_slug: Optional[str] = None + ) -> OrganizationMembership: ... - def get_organization_membership(self, organization_membership_id: str) -> dict: ... + def get_organization_membership( + self, organization_membership_id: str + ) -> OrganizationMembership: ... def list_organization_memberships( self, - user_id=None, - organization_id=None, - statuses=None, - limit=None, - before=None, - after=None, - order=None, - ) -> dict: ... + user_id: Optional[str] = None, + organization_id: Optional[str] = None, + statuses: Optional[Set[OrganizationMembershipStatus]] = None, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> SyncOrAsyncListResource: ... def delete_organization_membership( self, organization_membership_id: str @@ -104,177 +158,260 @@ def delete_organization_membership( def deactivate_organization_membership( self, organization_membership_id: str - ) -> dict: ... + ) -> OrganizationMembership: ... def reactivate_organization_membership( self, organization_membership_id: str - ) -> dict: ... + ) -> OrganizationMembership: ... def get_authorization_url( self, redirect_uri: str, - connection_id=None, - organization_id=None, - provider=None, - domain_hint=None, - login_hint=None, - state=None, - code_challenge=None, - ) -> str: ... + domain_hint: Optional[str] = None, + login_hint: Optional[str] = None, + state: Optional[str] = None, + provider: Optional[UserManagementProviderType] = None, + connection_id: Optional[str] = None, + organization_id: Optional[str] = None, + code_challenge: Optional[str] = None, + ) -> str: + """Generate an OAuth 2.0 authorization URL. + + The URL generated will redirect a User to the Identity Provider configured through + WorkOS. + + Kwargs: + redirect_uri (str) - A Redirect URI to return an authorized user to. + connection_id (str) - The connection_id connection selector is used to initiate SSO for a Connection. + The value of this parameter should be a WorkOS Connection ID. (Optional) + organization_id (str) - The organization_id connection selector is used to initiate SSO for an Organization. + The value of this parameter should be a WorkOS Organization ID. (Optional) + provider (UserManagementProviderType) - The provider connection selector is used to initiate SSO using an OAuth-compatible provider. + Currently, the supported values for provider are 'authkit', 'AppleOAuth', 'GitHubOAuth, 'GoogleOAuth', and 'MicrosoftOAuth'. (Optional) + domain_hint (str) - Can be used to pre-fill the domain field when initiating authentication with Microsoft OAuth, + or with a GoogleSAML connection type. (Optional) + login_hint (str) - Can be used to pre-fill the username/email address field of the IdP sign-in page for the user, + if you know their username ahead of time. Currently, this parameter is supported for OAuth, OpenID Connect, + OktaSAML, and AzureSAML connection types. (Optional) + state (str) - An encoded string passed to WorkOS that'd be preserved through the authentication workflow, passed + back as a query parameter. (Optional) + code_challenge (str) - Code challenge is derived from the code verifier used for the PKCE flow. (Optional) + + Returns: + str: URL to redirect a User to to begin the OAuth workflow with WorkOS + """ + params = { + "client_id": workos.client_id, + "redirect_uri": redirect_uri, + "response_type": RESPONSE_TYPE_CODE, + } + + if connection_id is None and organization_id is None and provider is None: + raise ValueError( + "Incomplete arguments. Need to specify either a 'connection_id', 'organization_id', or 'provider_id'" + ) + + if connection_id is not None: + params["connection_id"] = connection_id + if organization_id is not None: + params["organization_id"] = organization_id + if provider is not None: + params["provider"] = provider + if domain_hint is not None: + params["domain_hint"] = domain_hint + if login_hint is not None: + params["login_hint"] = login_hint + if state is not None: + params["state"] = state + if code_challenge: + params["code_challenge"] = code_challenge + params["code_challenge_method"] = "S256" + + return RequestHelper.build_url_with_query_params( + base_url=self._http_client.base_url, path=USER_AUTHORIZATION_PATH, **params + ) + + def _authenticate_with(self, payload) -> AuthenticationResponse: ... def authenticate_with_password( - self, email: str, password: str, ip_address=None, user_agent=None - ) -> dict: ... + self, + email: str, + password: str, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + ) -> AuthenticationResponse: ... def authenticate_with_code( - self, code: str, code_verifier=None, ip_address=None, user_agent=None - ) -> dict: ... + self, + code: str, + code_verifier: Optional[str] = None, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + ) -> AuthenticationResponse: ... def authenticate_with_magic_auth( self, code: str, email: str, - link_authorization_code=None, - ip_address=None, - user_agent=None, - ) -> dict: ... + link_authorization_code: Optional[str] = None, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + ) -> AuthenticationResponse: ... def authenticate_with_email_verification( self, code: str, pending_authentication_token: str, - ip_address=None, - user_agent=None, - ) -> dict: ... + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + ) -> AuthenticationResponse: ... def authenticate_with_totp( self, code: str, authentication_challenge_id: str, pending_authentication_token: str, - ip_address=None, - user_agent=None, - ) -> dict: ... + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + ) -> AuthenticationResponse: ... def authenticate_with_organization_selection( self, organization_id, pending_authentication_token, - ip_address=None, - user_agent=None, - ) -> dict: ... + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + ) -> AuthenticationResponse: ... def authenticate_with_refresh_token( self, - refresh_token, - ip_address=None, - user_agent=None, - ) -> dict: ... + refresh_token: str, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + ) -> RefreshTokenAuthenticationResponse: ... - # TODO: Methods that don't method network requests can just be defined in the base class - def get_jwks_url(self) -> str: ... + def get_jwks_url(self) -> str: + """Get the public key that is used for verifying access tokens. - # TODO: Methods that don't method network requests can just be defined in the base class - def get_logout_url(self, session_id) -> str: ... + Returns: + (str): The public JWKS URL. + """ - def get_password_reset(self, password_reset_id) -> dict: ... + return "%ssso/jwks/%s" % (workos.base_api_url, workos.client_id) - def create_password_reset(self, email) -> dict: ... + def get_logout_url(self, session_id: str) -> str: + """Get the URL for ending the session and redirecting the user - def send_password_reset_email(self, email, password_reset_url) -> None: ... + Kwargs: + session_id (str): The ID of the user's session - def reset_password(self, token, new_password) -> dict: ... + Returns: + (str): URL to redirect the user to to end the session. + """ - def get_email_verification(self, email_verification_id) -> dict: ... + return "%suser_management/sessions/logout?session_id=%s" % ( + workos.base_api_url, + session_id, + ) + + def get_password_reset(self, password_reset_id: str) -> PasswordReset: ... + + def create_password_reset(self, email: str) -> PasswordReset: ... - def send_verification_email(self, user_id) -> dict: ... + def reset_password(self, token: str, new_password: str) -> User: ... - def verify_email(self, user_id, code) -> dict: ... + def get_email_verification( + self, email_verification_id: str + ) -> EmailVerification: ... - def get_magic_auth(self, magic_auth_id) -> dict: ... + def send_verification_email(self, user_id: str) -> User: ... - def create_magic_auth(self, email, invitation_token=None) -> dict: ... + def verify_email(self, user_id: str, code: str) -> User: ... - def send_magic_auth_code(self, email) -> None: ... + def get_magic_auth(self, magic_auth_id: str) -> MagicAuth: ... + + def create_magic_auth( + self, email: str, invitation_token: Optional[str] = None + ) -> MagicAuth: ... def enroll_auth_factor( self, - user_id, - type, - totp_issuer=None, - totp_user=None, - totp_secret=None, - ) -> dict: ... + user_id: str, + type: AuthenticationFactorType, + totp_issuer: Optional[str] = None, + totp_user: Optional[str] = None, + totp_secret: Optional[str] = None, + ) -> AuthenticationFactorTotpAndChallengeResponse: ... - def list_auth_factors(self, user_id) -> WorkOSListResource: ... + def list_auth_factors( + self, + user_id: str, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> SyncOrAsyncListResource: ... - def get_invitation(self, invitation_id) -> dict: ... + def get_invitation(self, invitation_id: str) -> Invitation: ... - def find_invitation_by_token(self, invitation_token) -> dict: ... + def find_invitation_by_token(self, invitation_token: str) -> Invitation: ... def list_invitations( self, - email=None, - organization_id=None, - limit=None, - before=None, - after=None, - order=None, - ) -> WorkOSListResource: ... + email: Optional[str] = None, + organization_id: Optional[str] = None, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> SyncOrAsyncListResource: ... def send_invitation( self, - email, - organization_id=None, - expires_in_days=None, - inviter_user_id=None, - role_slug=None, - ) -> dict: ... + email: str, + organization_id: Optional[str] = None, + expires_in_days: Optional[int] = None, + inviter_user_id: Optional[str] = None, + role_slug: Optional[str] = None, + ) -> Invitation: ... - def revoke_invitation(self, invitation_id) -> dict: ... + def revoke_invitation(self, invitation_id) -> Invitation: ... class UserManagement(UserManagementModule, WorkOSListResource): """Offers methods for using the WorkOS User Management API.""" - @validate_settings(USER_MANAGEMENT_MODULE) - def __init__(self): - pass + _http_client: SyncHTTPClient - @property - def request_helper(self): - if not getattr(self, "_request_helper", None): - self._request_helper = RequestHelper() - return self._request_helper + @validate_settings(USER_MANAGEMENT_MODULE) + def __init__(self, http_client: SyncHTTPClient): + self._http_client = http_client - def get_user(self, user_id): + def get_user(self, user_id: str) -> User: """Get the details of an existing user. Args: user_id (str) - User unique identifier Returns: - dict: User response from WorkOS. + User: User response from WorkOS. """ - headers = {} - - response = self.request_helper.request( + response = self._http_client.request( USER_DETAIL_PATH.format(user_id), method=REQUEST_METHOD_GET, - headers=headers, token=workos.api_key, ) - return WorkOSUser.construct_from_response(response).to_dict() + return User.model_validate(response) def list_users( self, - email=None, - organization_id=None, - limit=None, - before=None, - after=None, - order=None, - ): + email: Optional[str] = None, + organization_id: Optional[str] = None, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> WorkOsListResource[User, UsersListFilters, ListMetadata]: """Get a list of all of your existing users matching the criteria specified. Kwargs: @@ -283,121 +420,134 @@ def list_users( limit (int): Maximum number of records to return. (Optional) before (str): Pagination cursor to receive records before a provided User ID. (Optional) after (str): Pagination cursor to receive records after a provided User ID. (Optional) - order (Order): Sort records in either ascending or descending order by created_at timestamp: "asc" or "desc" (Optional) + order (PaginationOrder): Sort records in either ascending or descending order by created_at timestamp: "asc" or "desc" (Optional) Returns: dict: Users response from WorkOS. """ - default_limit = None - - if limit is None: - limit = DEFAULT_LIST_RESPONSE_LIMIT - default_limit = True - - params = { + params: UsersListFilters = { "email": email, "organization_id": organization_id, "limit": limit, "before": before, "after": after, - "order": order or "desc", + "order": order, } - if order is not None: - if isinstance(order, Order): - params["order"] = str(order.value) - elif order == "asc" or order == "desc": - params["order"] = order - else: - raise ValueError("Parameter order must be of enum type Order") - - response = self.request_helper.request( + response = self._http_client.request( USER_PATH, method=REQUEST_METHOD_GET, params=params, token=workos.api_key, ) - response["metadata"] = { - "params": params, - "method": UserManagement.list_users, - } - - if "default_limit" in locals(): - if "metadata" in response and "params" in response["metadata"]: - response["metadata"]["params"]["default_limit"] = default_limit - else: - response["metadata"] = {"params": {"default_limit": default_limit}} - - return self.construct_from_response(response) + return WorkOsListResource[User, UsersListFilters, ListMetadata]( + list_method=self.list_users, + list_args=params, + **ListPage[User](**response).model_dump(), + ) - def create_user(self, user): + def create_user( + self, + email: str, + password: Optional[str] = None, + password_hash: Optional[str] = None, + password_hash_type: Optional[PasswordHashType] = None, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + email_verified: Optional[bool] = None, + ) -> User: """Create a new user. Args: - user (dict) - An user object - user[email] (str) - The email address of the user. - user[password] (str) - The password to set for the user. (Optional) - user[password_hash] (str) - The hashed password to set for the user. Mutually exclusive with password. (Optional) - user[password_hash_type] (str) - The algorithm originally used to hash the password, used when providing a password_hash. Valid values are 'bcrypt', `firebase-scrypt`, and `ssha`. (Optional) - user[first_name] (str) - The user's first name. (Optional) - user[last_name] (str) - The user's last name. (Optional) - user[email_verified] (bool) - Whether the user's email address was previously verified. (Optional) + email (str) - The email address of the user. + password (str) - The password to set for the user. (Optional) + password_hash (str) - The hashed password to set for the user. Mutually exclusive with password. (Optional) + password_hash_type (str) - The algorithm originally used to hash the password, used when providing a password_hash. Valid values are 'bcrypt', `firebase-scrypt`, and `ssha`. (Optional) + first_name (str) - The user's first name. (Optional) + last_name (str) - The user's last name. (Optional) + email_verified (bool) - Whether the user's email address was previously verified. (Optional) Returns: - dict: Created User response from WorkOS. + User: Created User response from WorkOS. """ - headers = {} + params = { + "email": email, + "password": password, + "password_hash": password_hash, + "password_hash_type": password_hash_type, + "first_name": first_name, + "last_name": last_name, + "email_verified": email_verified or False, + } - response = self.request_helper.request( + response = self._http_client.request( USER_PATH, method=REQUEST_METHOD_POST, - params=user, - headers=headers, + params=params, token=workos.api_key, ) - return WorkOSUser.construct_from_response(response).to_dict() + return User.model_validate(response) - def update_user(self, user_id, payload): + def update_user( + self, + user_id: str, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + email_verified: Optional[bool] = None, + password: Optional[str] = None, + password_hash: Optional[str] = None, + password_hash_type: Optional[PasswordHashType] = None, + ) -> User: """Update user attributes. Args: user_id (str) - The User unique identifier - payload (dict) - The User attributes to be updated - payload[first_name] (str) - The user's first name. (Optional) - payload[last_name] (str) - The user's last name. (Optional) - payload[email_verified] (bool) - Whether the user's email address was previously verified. (Optional) - payload[password] (str) - The password to set for the user. (Optional) - payload[password_hash] (str) - The hashed password to set for the user, used when migrating from another user store. Mutually exclusive with password. (Optional) - payload[password_hash_type] (str) - The algorithm originally used to hash the password, used when providing a password_hash. Valid values are 'bcrypt', `firebase-scrypt`, and `ssha`. (Optional) + first_name (str) - The user's first name. (Optional) + last_name (str) - The user's last name. (Optional) + email_verified (bool) - Whether the user's email address was previously verified. (Optional) + password (str) - The password to set for the user. (Optional) + password_hash (str) - The hashed password to set for the user, used when migrating from another user store. Mutually exclusive with password. (Optional) + password_hash_type (str) - The algorithm originally used to hash the password, used when providing a password_hash. Valid values are 'bcrypt', `firebase-scrypt`, and `ssha`. (Optional) Returns: - dict: Updated User response from WorkOS. + User: Updated User response from WorkOS. """ - response = self.request_helper.request( + params = { + "first_name": first_name, + "last_name": last_name, + "email_verified": email_verified, + "password": password, + "password_hash": password_hash, + "password_hash_type": password_hash_type, + } + + response = self._http_client.request( USER_DETAIL_PATH.format(user_id), method=REQUEST_METHOD_PUT, - params=payload, + params=params, token=workos.api_key, ) - return WorkOSUser.construct_from_response(response).to_dict() + return User.model_validate(response) - def delete_user(self, user_id): + def delete_user(self, user_id: str) -> None: """Delete an existing user. Args: user_id (str) - User unique identifier """ - self.request_helper.request( + self._http_client.request( USER_DETAIL_PATH.format(user_id), method=REQUEST_METHOD_DELETE, token=workos.api_key, ) - def create_organization_membership(self, user_id, organization_id, role_slug=None): + def create_organization_membership( + self, user_id: str, organization_id: str, role_slug: Optional[str] = None + ) -> OrganizationMembership: """Create a new OrganizationMembership for the given Organization and User. Args: @@ -407,9 +557,8 @@ def create_organization_membership(self, user_id, organization_id, role_slug=Non If no slug is passed in, the default role will be granted.(Optional) Returns: - dict: Created OrganizationMembership response from WorkOS. + OrganizationMembership: Created OrganizationMembership response from WorkOS. """ - headers = {} params = { "user_id": user_id, @@ -417,19 +566,18 @@ def create_organization_membership(self, user_id, organization_id, role_slug=Non "role_slug": role_slug, } - response = self.request_helper.request( + response = self._http_client.request( ORGANIZATION_MEMBERSHIP_PATH, method=REQUEST_METHOD_POST, params=params, - headers=headers, token=workos.api_key, ) - return WorkOSOrganizationMembership.construct_from_response(response).to_dict() + return OrganizationMembership.model_validate(response) def update_organization_membership( - self, organization_membership_id, role_slug=None - ): + self, organization_membership_id: str, role_slug: Optional[str] = None + ) -> OrganizationMembership: """Updates an OrganizationMembership for the given id. Args: @@ -438,53 +586,53 @@ def update_organization_membership( If no slug is passed in, it will not be changed (Optional) Returns: - dict: Created OrganizationMembership response from WorkOS. + OrganizationMembership: Updated OrganizationMembership response from WorkOS. """ - headers = {} params = { "role_slug": role_slug, } - response = self.request_helper.request( + response = self._http_client.request( ORGANIZATION_MEMBERSHIP_DETAIL_PATH.format(organization_membership_id), method=REQUEST_METHOD_PUT, params=params, - headers=headers, token=workos.api_key, ) - return WorkOSOrganizationMembership.construct_from_response(response).to_dict() + return OrganizationMembership.model_validate(response) - def get_organization_membership(self, organization_membership_id): + def get_organization_membership( + self, organization_membership_id: str + ) -> OrganizationMembership: """Get the details of an organization membership. Args: organization_membership_id (str) - The unique ID of the Organization Membership. Returns: - dict: OrganizationMembership response from WorkOS. + OrganizationMembership: OrganizationMembership response from WorkOS. """ - headers = {} - response = self.request_helper.request( + response = self._http_client.request( ORGANIZATION_MEMBERSHIP_DETAIL_PATH.format(organization_membership_id), method=REQUEST_METHOD_GET, - headers=headers, token=workos.api_key, ) - return WorkOSOrganizationMembership.construct_from_response(response).to_dict() + return OrganizationMembership.model_validate(response) def list_organization_memberships( self, - user_id=None, - organization_id=None, - statuses=None, - limit=None, - before=None, - after=None, - order=None, - ): + user_id: Optional[str] = None, + organization_id: Optional[str] = None, + statuses: Optional[Set[OrganizationMembershipStatus]] = None, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> WorkOsListResource[ + OrganizationMembership, OrganizationMembershipsListFilters, ListMetadata + ]: """Get a list of all of your existing organization memberships matching the criteria specified. Kwargs: @@ -497,183 +645,106 @@ def list_organization_memberships( order (Order): Sort records in either ascending or descending order by created_at timestamp: "asc" or "desc" (Optional) Returns: - dict: Organization Memberships response from WorkOS. + WorkOsListResource: Organization Memberships response from WorkOS. """ - default_limit = None - - if limit is None: - limit = DEFAULT_LIST_RESPONSE_LIMIT - default_limit = True - - if statuses is not None: - statuses = ",".join(statuses) - - params = { + params: OrganizationMembershipsListFilters = { "user_id": user_id, "organization_id": organization_id, - "statuses": statuses, + "statuses": ",".join(statuses) if statuses else None, "limit": limit, "before": before, "after": after, - "order": order or "desc", + "order": order, } - if order is not None: - if isinstance(order, Order): - params["order"] = str(order.value) - elif order == "asc" or order == "desc": - params["order"] = order - else: - raise ValueError("Parameter order must be of enum type Order") - - response = self.request_helper.request( + response = self._http_client.request( ORGANIZATION_MEMBERSHIP_PATH, method=REQUEST_METHOD_GET, params=params, token=workos.api_key, ) - response["metadata"] = { - "params": params, - "method": UserManagement.list_organization_memberships, - } - - if "default_limit" in locals(): - if "metadata" in response and "params" in response["metadata"]: - response["metadata"]["params"]["default_limit"] = default_limit - else: - response["metadata"] = {"params": {"default_limit": default_limit}} - - return self.construct_from_response(response) + return WorkOsListResource[ + OrganizationMembership, + OrganizationMembershipsListFilters, + ListMetadata, + ]( + list_method=self.list_organization_memberships, + list_args=params, + **ListPage[OrganizationMembership](**response).model_dump(), + ) - def delete_organization_membership(self, organization_membership_id): + def delete_organization_membership(self, organization_membership_id: str) -> None: """Delete an existing organization membership. Args: organization_membership_id (str) - The unique ID of the Organization Membership. """ - self.request_helper.request( + self._http_client.request( ORGANIZATION_MEMBERSHIP_DETAIL_PATH.format(organization_membership_id), method=REQUEST_METHOD_DELETE, token=workos.api_key, ) - def deactivate_organization_membership(self, organization_membership_id): + def deactivate_organization_membership( + self, organization_membership_id: str + ) -> OrganizationMembership: """Deactivate an organization membership. Args: organization_membership_id (str) - The unique ID of the Organization Membership. Returns: - dict: OrganizationMembership response from WorkOS. + OrganizationMembership: OrganizationMembership response from WorkOS. """ - response = self.request_helper.request( + response = self._http_client.request( ORGANIZATION_MEMBERSHIP_DEACTIVATE_PATH.format(organization_membership_id), method=REQUEST_METHOD_PUT, token=workos.api_key, ) - return WorkOSOrganizationMembership.construct_from_response(response).to_dict() + return OrganizationMembership.model_validate(response) - def reactivate_organization_membership(self, organization_membership_id): + def reactivate_organization_membership( + self, organization_membership_id: str + ) -> OrganizationMembership: """Reactivates an organization membership. Args: organization_membership_id (str) - The unique ID of the Organization Membership. Returns: - dict: OrganizationMembership response from WorkOS. + OrganizationMembership: OrganizationMembership response from WorkOS. """ - response = self.request_helper.request( + response = self._http_client.request( ORGANIZATION_MEMBERSHIP_REACTIVATE_PATH.format(organization_membership_id), method=REQUEST_METHOD_PUT, token=workos.api_key, ) - return WorkOSOrganizationMembership.construct_from_response(response).to_dict() - - def get_authorization_url( - self, - redirect_uri, - connection_id=None, - organization_id=None, - provider=None, - domain_hint=None, - login_hint=None, - state=None, - code_challenge=None, - ): - """Generate an OAuth 2.0 authorization URL. + return OrganizationMembership.model_validate(response) - The URL generated will redirect a User to the Identity Provider configured through - WorkOS. - - Kwargs: - redirect_uri (str) - A Redirect URI to return an authorized user to. - connection_id (str) - The connection_id connection selector is used to initiate SSO for a Connection. - The value of this parameter should be a WorkOS Connection ID. (Optional) - organization_id (str) - The organization_id connection selector is used to initiate SSO for an Organization. - The value of this parameter should be a WorkOS Organization ID. (Optional) - provider (UserManagementProviderType) - The provider connection selector is used to initiate SSO using an OAuth-compatible provider. - Currently, the supported values for provider are 'authkit', 'AppleOAuth', 'GitHubOAuth, 'GoogleOAuth', and 'MicrosoftOAuth'. (Optional) - domain_hint (str) - Can be used to pre-fill the domain field when initiating authentication with Microsoft OAuth, - or with a GoogleSAML connection type. (Optional) - login_hint (str) - Can be used to pre-fill the username/email address field of the IdP sign-in page for the user, - if you know their username ahead of time. Currently, this parameter is supported for OAuth, OpenID Connect, - OktaSAML, and AzureSAML connection types. (Optional) - state (str) - An encoded string passed to WorkOS that'd be preserved through the authentication workflow, passed - back as a query parameter. (Optional) - code_challenge (str) - Code challenge is derived from the code verifier used for the PKCE flow. (Optional) - - Returns: - str: URL to redirect a User to to begin the OAuth workflow with WorkOS - """ + def _authenticate_with(self, payload) -> AuthenticationResponse: params = { "client_id": workos.client_id, - "redirect_uri": redirect_uri, - "response_type": RESPONSE_TYPE_CODE, + "client_secret": workos.api_key, + **payload, } - if connection_id is None and organization_id is None and provider is None: - raise ValueError( - "Incomplete arguments. Need to specify either a 'connection_id', 'organization_id', or 'provider_id'" - ) - - if connection_id is not None: - params["connection_id"] = connection_id - if organization_id is not None: - params["organization_id"] = organization_id - if provider is not None: - if not isinstance(provider, UserManagementProviderType): - raise ValueError( - "'provider' must be of type UserManagementProviderType" - ) - - params["provider"] = provider.value - if domain_hint is not None: - params["domain_hint"] = domain_hint - if login_hint is not None: - params["login_hint"] = login_hint - if state is not None: - params["state"] = state - if code_challenge: - params["code_challenge"] = code_challenge - params["code_challenge_method"] = "S256" - - prepared_request = Request( - "GET", - self.request_helper.generate_api_url(USER_AUTHORIZATION_PATH), + response = self._http_client.request( + USER_AUTHENTICATE_PATH, + method=REQUEST_METHOD_POST, params=params, - ).prepare() + ) - return prepared_request.url + return AuthenticationResponse.model_validate(response) def authenticate_with_password( self, - email, - password, - ip_address=None, - user_agent=None, - ): + email: str, + password: str, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + ) -> AuthenticationResponse: """Authenticates a user with email and password. Kwargs: @@ -683,43 +754,26 @@ def authenticate_with_password( user_agent (str): The user agent of the request from the user who is attempting to authenticate. (Optional) Returns: - (dict): Authentication response from WorkOS. - [user] (dict): User response from WorkOS - [organization_id] (str): The Organization the user selected to sign in for, if applicable. + AuthenticationResponse: Authentication response from WorkOS. """ - headers = {} - payload = { - "client_id": workos.client_id, - "client_secret": workos.api_key, "email": email, "password": password, "grant_type": "password", + "ip_address": ip_address, + "user_agent": user_agent, } - if ip_address: - payload["ip_address"] = ip_address - - if user_agent: - payload["user_agent"] = user_agent - - response = self.request_helper.request( - USER_AUTHENTICATE_PATH, - method=REQUEST_METHOD_POST, - headers=headers, - params=payload, - ) - - return WorkOSAuthenticationResponse.construct_from_response(response).to_dict() + return self._authenticate_with(payload) def authenticate_with_code( self, - code, - code_verifier=None, - ip_address=None, - user_agent=None, - ): + code: str, + code_verifier: Optional[str] = None, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + ) -> AuthenticationResponse: """Authenticates an OAuth user or a user that is logging in through SSO. Kwargs: @@ -735,41 +789,24 @@ def authenticate_with_code( [organization_id] (str): The Organization the user selected to sign in for, if applicable. """ - headers = {} - payload = { - "client_id": workos.client_id, - "client_secret": workos.api_key, "code": code, "grant_type": "authorization_code", + "ip_address": ip_address, + "user_agent": user_agent, + "code_verifier": code_verifier, } - if ip_address: - payload["ip_address"] = ip_address - - if user_agent: - payload["user_agent"] = user_agent - - if code_verifier: - payload["code_verifier"] = code_verifier - - response = self.request_helper.request( - USER_AUTHENTICATE_PATH, - method=REQUEST_METHOD_POST, - headers=headers, - params=payload, - ) - - return WorkOSAuthenticationResponse.construct_from_response(response).to_dict() + return self._authenticate_with(payload) def authenticate_with_magic_auth( self, - code, - email, - link_authorization_code=None, - ip_address=None, - user_agent=None, - ): + code: str, + email: str, + link_authorization_code: Optional[str] = None, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + ) -> AuthenticationResponse: """Authenticates a user by verifying a one-time code sent to the user's email address by the Magic Auth Send Code endpoint. Kwargs: @@ -780,46 +817,27 @@ def authenticate_with_magic_auth( user_agent (str): The user agent of the request from the user who is attempting to authenticate. (Optional) Returns: - (dict): Authentication response from WorkOS. - [user] (dict): User response from WorkOS - [organization_id] (str): The Organization the user selected to sign in for, if applicable. + AuthenticationResponse: Authentication response from WorkOS. """ - headers = {} - payload = { - "client_id": workos.client_id, - "client_secret": workos.api_key, "code": code, "email": email, "grant_type": "urn:workos:oauth:grant-type:magic-auth:code", + "link_authorization_code": link_authorization_code, + "ip_address": ip_address, + "user_agent": user_agent, } - if link_authorization_code: - payload["link_authorization_code"] = link_authorization_code - - if ip_address: - payload["ip_address"] = ip_address - - if user_agent: - payload["user_agent"] = user_agent - - response = self.request_helper.request( - USER_AUTHENTICATE_PATH, - method=REQUEST_METHOD_POST, - headers=headers, - params=payload, - ) - - return WorkOSAuthenticationResponse.construct_from_response(response).to_dict() + return self._authenticate_with(payload) def authenticate_with_email_verification( self, - code, - pending_authentication_token, - ip_address=None, - user_agent=None, - ): + code: str, + pending_authentication_token: str, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + ) -> AuthenticationResponse: """Authenticates a user that requires email verification by verifying a one-time code sent to the user's email address and the pending authentication token. Kwargs: @@ -829,44 +847,29 @@ def authenticate_with_email_verification( user_agent (str): The user agent of the request from the user who is attempting to authenticate. (Optional) Returns: - (dict): Authentication response from WorkOS. - [user] (dict): User response from WorkOS - [organization_id] (str): The Organization the user selected to sign in for, if applicable. + AuthenticationResponse: Authentication response from WorkOS. """ - headers = {} - payload = { "client_id": workos.client_id, "client_secret": workos.api_key, "code": code, "pending_authentication_token": pending_authentication_token, "grant_type": "urn:workos:oauth:grant-type:email-verification:code", + "ip_address": ip_address, + "user_agent": user_agent, } - if ip_address: - payload["ip_address"] = ip_address - - if user_agent: - payload["user_agent"] = user_agent - - response = self.request_helper.request( - USER_AUTHENTICATE_PATH, - method=REQUEST_METHOD_POST, - headers=headers, - params=payload, - ) - - return WorkOSAuthenticationResponse.construct_from_response(response).to_dict() + return self._authenticate_with(payload) def authenticate_with_totp( self, - code, - authentication_challenge_id, - pending_authentication_token, - ip_address=None, - user_agent=None, - ): + code: str, + authentication_challenge_id: str, + pending_authentication_token: str, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + ) -> AuthenticationResponse: """Authenticates a user that has MFA enrolled by verifying the TOTP code, the Challenge from the Factor, and the pending authentication token. Kwargs: @@ -877,44 +880,27 @@ def authenticate_with_totp( user_agent (str): The user agent of the request from the user who is attempting to authenticate. (Optional) Returns: - (dict): Authentication response from WorkOS. - [user] (dict): User response from WorkOS - [organization_id] (str): The Organization the user selected to sign in for, if applicable. + AuthenticationResponse: Authentication response from WorkOS. """ - headers = {} - payload = { - "client_id": workos.client_id, - "client_secret": workos.api_key, "code": code, "authentication_challenge_id": authentication_challenge_id, "pending_authentication_token": pending_authentication_token, "grant_type": "urn:workos:oauth:grant-type:mfa-totp", + "ip_address": ip_address, + "user_agent": user_agent, } - if ip_address: - payload["ip_address"] = ip_address - - if user_agent: - payload["user_agent"] = user_agent - - response = self.request_helper.request( - USER_AUTHENTICATE_PATH, - method=REQUEST_METHOD_POST, - headers=headers, - params=payload, - ) - - return WorkOSAuthenticationResponse.construct_from_response(response).to_dict() + return self._authenticate_with(payload) def authenticate_with_organization_selection( self, - organization_id, - pending_authentication_token, - ip_address=None, - user_agent=None, - ): + organization_id: str, + pending_authentication_token: str, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + ) -> AuthenticationResponse: """Authenticates a user that is a member of multiple organizations by verifying the organization ID and the pending authentication token. Kwargs: @@ -924,42 +910,27 @@ def authenticate_with_organization_selection( user_agent (str): The user agent of the request from the user who is attempting to authenticate. (Optional) Returns: - (dict): Authentication response from WorkOS. - [user] (dict): User response from WorkOS - [organization_id] (str): The Organization the user selected to sign in for, if applicable. + AuthenticationResponse: Authentication response from WorkOS. """ - headers = {} - payload = { "client_id": workos.client_id, "client_secret": workos.api_key, "organization_id": organization_id, "pending_authentication_token": pending_authentication_token, "grant_type": "urn:workos:oauth:grant-type:organization-selection", + "ip_address": ip_address, + "user_agent": user_agent, } - if ip_address: - payload["ip_address"] = ip_address - - if user_agent: - payload["user_agent"] = user_agent - - response = self.request_helper.request( - USER_AUTHENTICATE_PATH, - method=REQUEST_METHOD_POST, - headers=headers, - params=payload, - ) - - return WorkOSAuthenticationResponse.construct_from_response(response).to_dict() + return self._authenticate_with(payload) def authenticate_with_refresh_token( self, - refresh_token, - ip_address=None, - user_agent=None, - ): + refresh_token: str, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + ) -> RefreshTokenAuthenticationResponse: """Authenticates a user with a refresh token. Kwargs: @@ -968,85 +939,45 @@ def authenticate_with_refresh_token( user_agent (str): The user agent of the request from the user who is attempting to authenticate. (Optional) Returns: - (dict): Refresh Token Authentication response from WorkOS. - [access_token] (str): The refreshed access token - [refresh_token] (str): The new refresh token. + RefreshTokenAuthenticationResponse: Refresh Token Authentication response from WorkOS. """ - headers = {} - payload = { "client_id": workos.client_id, "client_secret": workos.api_key, "refresh_token": refresh_token, "grant_type": "refresh_token", + "ip_address": ip_address, + "user_agent": user_agent, } - if ip_address: - payload["ip_address"] = ip_address - - if user_agent: - payload["user_agent"] = user_agent - - response = self.request_helper.request( + response = self._http_client.request( USER_AUTHENTICATE_PATH, method=REQUEST_METHOD_POST, - headers=headers, params=payload, ) - return WorkOSRefreshTokenAuthenticationResponse.construct_from_response( - response - ).to_dict() - - def get_jwks_url(self): - """Get the public key that is used for verifying access tokens. + return RefreshTokenAuthenticationResponse.model_validate(response) - Returns: - (str): The public JWKS URL. - """ - - return "%ssso/jwks/%s" % (workos.base_api_url, workos.client_id) - - def get_logout_url(self, session_id): - """Get the URL for ending the session and redirecting the user - - Kwargs: - session_id (str): The ID of the user's session - - Returns: - (str): URL to redirect the user to to end the session. - """ - - return "%suser_management/sessions/logout?session_id=%s" % ( - workos.base_api_url, - session_id, - ) - - def get_password_reset(self, password_reset_id): + def get_password_reset(self, password_reset_id: str) -> PasswordReset: """Get the details of a password reset object. Args: password_reset_id (str) - The unique ID of the password reset object. Returns: - dict: PasswordReset response from WorkOS. + PasswordReset: PasswordReset response from WorkOS. """ - headers = {} - response = self.request_helper.request( + response = self._http_client.request( PASSWORD_RESET_DETAIL_PATH.format(password_reset_id), method=REQUEST_METHOD_GET, - headers=headers, token=workos.api_key, ) - return WorkOSPasswordReset.construct_from_response(response).to_dict() + return PasswordReset.model_validate(response) - def create_password_reset( - self, - email, - ): + def create_password_reset(self, email: str) -> PasswordReset: """Creates a password reset token that can be sent to a user's email to reset the password. Args: @@ -1055,61 +986,21 @@ def create_password_reset( Returns: dict: PasswordReset response from WorkOS. """ - headers = {} params = { "email": email, } - response = self.request_helper.request( + response = self._http_client.request( PASSWORD_RESET_PATH, method=REQUEST_METHOD_POST, params=params, - headers=headers, token=workos.api_key, ) - return WorkOSPasswordReset.construct_from_response(response).to_dict() - - def send_password_reset_email( - self, - email, - password_reset_url, - ): - """Sends a password reset email to a user. + return PasswordReset.model_validate(response) - Deprecated: Please use `create_password_reset` instead. This method will be removed in a future major version. - - Kwargs: - email (str): The email of the user that wishes to reset their password. - password_reset_url (str): The URL that will be linked to in the email. - """ - - warn( - "'send_password_reset_email' is deprecated. Please use 'create_password_reset' instead. This method will be removed in a future major version.", - DeprecationWarning, - ) - - headers = {} - - payload = { - "email": email, - "password_reset_url": password_reset_url, - } - - self.request_helper.request( - USER_SEND_PASSWORD_RESET_PATH, - method=REQUEST_METHOD_POST, - headers=headers, - params=payload, - token=workos.api_key, - ) - - def reset_password( - self, - token, - new_password, - ): + def reset_password(self, token: str, new_password: str) -> User: """Resets user password using token that was sent to the user. Kwargs: @@ -1117,127 +1008,106 @@ def reset_password( new_password (str): The new password to be set for the user. Returns: - dict: User response from WorkOS. + User: User response from WorkOS. """ - headers = {} - payload = { "token": token, "new_password": new_password, } - response = self.request_helper.request( + response = self._http_client.request( USER_RESET_PASSWORD_PATH, method=REQUEST_METHOD_POST, - headers=headers, params=payload, token=workos.api_key, ) - return WorkOSUser.construct_from_response(response["user"]).to_dict() + return User.model_validate(response["user"]) - def get_email_verification(self, email_verification_id): + def get_email_verification(self, email_verification_id: str) -> EmailVerification: """Get the details of an email verification object. Args: - email_verificationh_id (str) - The unique ID of the email verification object. + email_verification_id (str) - The unique ID of the email verification object. Returns: - dict: EmailVerification response from WorkOS. + EmailVerification: EmailVerification response from WorkOS. """ - headers = {} - response = self.request_helper.request( + response = self._http_client.request( EMAIL_VERIFICATION_DETAIL_PATH.format(email_verification_id), method=REQUEST_METHOD_GET, - headers=headers, token=workos.api_key, ) - return WorkOSEmailVerification.construct_from_response(response).to_dict() + return EmailVerification.model_validate(response) - def send_verification_email( - self, - user_id, - ): + def send_verification_email(self, user_id: str) -> User: """Sends a verification email to the provided user. Kwargs: user_id (str): The unique ID of the User whose email address will be verified. Returns: - dict: User response from WorkOS. + User: User response from WorkOS. """ - headers = {} - - response = self.request_helper.request( + response = self._http_client.request( USER_SEND_VERIFICATION_EMAIL_PATH.format(user_id), method=REQUEST_METHOD_POST, - headers=headers, token=workos.api_key, ) - return WorkOSUser.construct_from_response(response["user"]).to_dict() + return User.model_validate(response["user"]) - def verify_email( - self, - user_id, - code, - ): + def verify_email(self, user_id: str, code: str) -> User: """Verifies user email using one-time code that was sent to the user. Kwargs: user_id (str): The unique ID of the User whose email address will be verified. - code (str): The one-time code emailed to the user. Returns: - dict: User response from WorkOS. + User: User response from WorkOS. """ - headers = {} - payload = { "code": code, } - response = self.request_helper.request( + response = self._http_client.request( USER_VERIFY_EMAIL_CODE_PATH.format(user_id), method=REQUEST_METHOD_POST, - headers=headers, params=payload, token=workos.api_key, ) - return WorkOSUser.construct_from_response(response["user"]).to_dict() + return User.model_validate(response["user"]) - def get_magic_auth(self, magic_auth_id): + def get_magic_auth(self, magic_auth_id: str) -> MagicAuth: """Get the details of a Magic Auth object. Args: magic_auth_id (str) - The unique ID of the Magic Auth object. Returns: - dict: MagicAuth response from WorkOS. + MagicAuth: MagicAuth response from WorkOS. """ - headers = {} - response = self.request_helper.request( + response = self._http_client.request( MAGIC_AUTH_DETAIL_PATH.format(magic_auth_id), method=REQUEST_METHOD_GET, - headers=headers, token=workos.api_key, ) - return WorkOSMagicAuth.construct_from_response(response).to_dict() + return MagicAuth.model_validate(response) def create_magic_auth( self, - email, - invitation_token=None, - ): + email: str, + invitation_token: Optional[str] = None, + ) -> MagicAuth: """Creates a Magic Auth code challenge that can be sent to a user's email for authentication. Args: @@ -1247,62 +1117,29 @@ def create_magic_auth( Returns: dict: MagicAuth response from WorkOS. """ - headers = {} params = { "email": email, "invitation_token": invitation_token, } - response = self.request_helper.request( + response = self._http_client.request( MAGIC_AUTH_PATH, method=REQUEST_METHOD_POST, params=params, - headers=headers, token=workos.api_key, ) - return WorkOSMagicAuth.construct_from_response(response).to_dict() - - def send_magic_auth_code( - self, - email, - ): - """Creates a one-time Magic Auth code and emails it to the user. - - Deprecated: Please use `create_magic_auth` instead. This method will be removed in a future major version. - - Kwargs: - email (str): The email address the one-time code will be sent to. - """ - - warn( - "'send_magic_auth_code' is deprecated. Please use 'create_magic_auth' instead. This method will be removed in a future major version.", - DeprecationWarning, - ) - - headers = {} - - payload = { - "email": email, - } - - response = self.request_helper.request( - USER_SEND_MAGIC_AUTH_PATH, - method=REQUEST_METHOD_POST, - headers=headers, - params=payload, - token=workos.api_key, - ) + return MagicAuth.model_validate(response) def enroll_auth_factor( self, - user_id, - type, - totp_issuer=None, - totp_user=None, - totp_secret=None, - ): + user_id: str, + type: AuthenticationFactorType, + totp_issuer: Optional[str] = None, + totp_user: Optional[str] = None, + totp_secret: Optional[str] = None, + ) -> AuthenticationFactorTotpAndChallengeResponse: """Enrolls a user in a new auth factor. Kwargs: @@ -1312,14 +1149,9 @@ def enroll_auth_factor( totp_user (str): Email of user (Optional) totp_secret (str): The secret key for the TOTP factor. Generated if not provided. (Optional) - Returns: { WorkOSAuthenticationFactorTotp, WorkOSChallenge} + Returns: AuthenticationFactorTotpAndChallengeResponse """ - if type not in ["totp"]: - raise ValueError("Type parameter must be 'totp'") - - headers = {} - payload = { "type": type, "totp_issuer": totp_issuer, @@ -1327,105 +1159,110 @@ def enroll_auth_factor( "totp_secret": totp_secret, } - response = self.request_helper.request( + response = self._http_client.request( USER_AUTH_FACTORS_PATH.format(user_id), method=REQUEST_METHOD_POST, - headers=headers, params=payload, token=workos.api_key, ) - factor_and_challenge = {} - factor_and_challenge["authentication_factor"] = ( - WorkOSAuthenticationFactorTotp.construct_from_response( - response["authentication_factor"] - ).to_dict() - ) - - factor_and_challenge["authentication_challenge"] = ( - WorkOSChallenge.construct_from_response( - response["authentication_challenge"] - ).to_dict() - ) - - return factor_and_challenge + return AuthenticationFactorTotpAndChallengeResponse.model_validate(response) def list_auth_factors( self, - user_id, - ): + user_id: str, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> WorkOsListResource[ + AuthenticationFactor, AuthenticationFactorsListFilters, ListMetadata + ]: """Lists the Auth Factors for a user. Kwargs: user_id (str): The unique ID of the User to list the auth factors for. Returns: - dict: List of Authentication Factors for a User from WorkOS. + WorkOsListResource: List of Authentication Factors for a User from WorkOS. """ - response = self.request_helper.request( + + params: ListArgs = { + "limit": limit, + "before": before, + "after": after, + "order": order, + } + + response = self._http_client.request( USER_AUTH_FACTORS_PATH.format(user_id), method=REQUEST_METHOD_GET, + params=params, token=workos.api_key, ) - response["metadata"] = { - "params": { - "user_id": user_id, - }, - "method": UserManagement.list_auth_factors, + # We don't spread params on this dict to make mypy happy + list_args: AuthenticationFactorsListFilters = { + "limit": limit or DEFAULT_LIST_RESPONSE_LIMIT, + "before": before, + "after": after, + "order": order, + "user_id": user_id, } - return self.construct_from_response(response) + return WorkOsListResource[ + AuthenticationFactor, AuthenticationFactorsListFilters, ListMetadata + ]( + list_method=self.list_auth_factors, + list_args=list_args, + **ListPage[AuthenticationFactor](**response).model_dump(), + ) - def get_invitation(self, invitation_id): + def get_invitation(self, invitation_id: str) -> Invitation: """Get the details of an invitation. Args: invitation_id (str) - The unique ID of the Invitation. Returns: - dict: Invitation response from WorkOS. + Invitation: Invitation response from WorkOS. """ - headers = {} - response = self.request_helper.request( + response = self._http_client.request( INVITATION_DETAIL_PATH.format(invitation_id), method=REQUEST_METHOD_GET, - headers=headers, token=workos.api_key, ) - return WorkOSInvitation.construct_from_response(response).to_dict() + return Invitation.model_validate(response) - def find_invitation_by_token(self, invitation_token): + def find_invitation_by_token(self, invitation_token: str) -> Invitation: """Get the details of an invitation. Args: invitation_token (str) - The token of the Invitation. Returns: - dict: Invitation response from WorkOS. + Invitation: Invitation response from WorkOS. """ - headers = {} - response = self.request_helper.request( + response = self._http_client.request( INVITATION_DETAIL_BY_TOKEN_PATH.format(invitation_token), method=REQUEST_METHOD_GET, - headers=headers, token=workos.api_key, ) - return WorkOSInvitation.construct_from_response(response).to_dict() + return Invitation.model_validate(response) def list_invitations( self, - email=None, - organization_id=None, - limit=None, - before=None, - after=None, - order=None, - ): + email: Optional[str] = None, + organization_id: Optional[str] = None, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + before: Optional[str] = None, + after: Optional[str] = None, + order: PaginationOrder = "desc", + ) -> WorkOsListResource[Invitation, InvitationsListFilters, ListMetadata]: """Get a list of all of your existing invitations matching the criteria specified. Kwargs: @@ -1437,60 +1274,39 @@ def list_invitations( order (Order): Sort records in either ascending or descending order by created_at timestamp: "asc" or "desc" (Optional) Returns: - dict: Users response from WorkOS. + WorkOsListResource: Invitations list response from WorkOS. """ - default_limit = None - - if limit is None: - limit = DEFAULT_LIST_RESPONSE_LIMIT - default_limit = True - - params = { + params: InvitationsListFilters = { "email": email, "organization_id": organization_id, "limit": limit, "before": before, "after": after, - "order": order or "desc", + "order": order, } - if order is not None: - if isinstance(order, Order): - params["order"] = str(order.value) - elif order == "asc" or order == "desc": - params["order"] = order - else: - raise ValueError("Parameter order must be of enum type Order") - - response = self.request_helper.request( + response = self._http_client.request( INVITATION_PATH, method=REQUEST_METHOD_GET, params=params, token=workos.api_key, ) - response["metadata"] = { - "params": params, - "method": UserManagement.list_invitations, - } - - if "default_limit" in locals(): - if "metadata" in response and "params" in response["metadata"]: - response["metadata"]["params"]["default_limit"] = default_limit - else: - response["metadata"] = {"params": {"default_limit": default_limit}} - - return self.construct_from_response(response) + return WorkOsListResource[Invitation, InvitationsListFilters, ListMetadata]( + list_method=self.list_invitations, + list_args=params, + **ListPage[Invitation](**response).model_dump(), + ) def send_invitation( self, - email, - organization_id=None, - expires_in_days=None, - inviter_user_id=None, - role_slug=None, - ): + email: str, + organization_id: Optional[str] = None, + expires_in_days: Optional[int] = None, + inviter_user_id: Optional[str] = None, + role_slug: Optional[str] = None, + ) -> Invitation: """Sends an Invitation to a recipient. Args: @@ -1503,7 +1319,6 @@ def send_invitation( Returns: dict: Sent Invitation response from WorkOS. """ - headers = {} params = { "email": email, @@ -1513,32 +1328,29 @@ def send_invitation( "role_slug": role_slug, } - response = self.request_helper.request( + response = self._http_client.request( INVITATION_PATH, method=REQUEST_METHOD_POST, params=params, - headers=headers, token=workos.api_key, ) - return WorkOSInvitation.construct_from_response(response).to_dict() + return Invitation.model_validate(response) - def revoke_invitation(self, invitation_id): + def revoke_invitation(self, invitation_id: str) -> Invitation: """Revokes an existing Invitation. Args: invitation_id (str) - The unique ID of the Invitation. Returns: - dict: Invitation response from WorkOS. + Invitation: Invitation response from WorkOS. """ - headers = {} - response = self.request_helper.request( + response = self._http_client.request( INVITATION_REVOKE_PATH.format(invitation_id), method=REQUEST_METHOD_POST, - headers=headers, token=workos.api_key, ) - return WorkOSInvitation.construct_from_response(response).to_dict() + return Invitation.model_validate(response) diff --git a/workos/utils/_base_http_client.py b/workos/utils/_base_http_client.py index 13b5637b..f2545148 100644 --- a/workos/utils/_base_http_client.py +++ b/workos/utils/_base_http_client.py @@ -1,16 +1,14 @@ import platform from typing import ( - Mapping, cast, Dict, Generic, Mapping, Optional, TypeVar, - TypedDict, Union, ) -from typing_extensions import NotRequired +from typing_extensions import NotRequired, TypedDict import httpx diff --git a/workos/utils/request.py b/workos/utils/request.py index 09173f18..f287e7e7 100644 --- a/workos/utils/request.py +++ b/workos/utils/request.py @@ -53,8 +53,8 @@ def build_parameterized_url(self, url, **params): return url.format(**escaped_params) @classmethod - def build_url_with_query_params(cls, url, **params): - return url.format("?" + urllib.parse.urlencode(params)) + def build_url_with_query_params(cls, base_url: str, path: str, **params): + return base_url.format(path) + "?" + urllib.parse.urlencode(params) def request( self, diff --git a/workos/utils/um_provider_types.py b/workos/utils/um_provider_types.py index 01532ddb..6451221b 100644 --- a/workos/utils/um_provider_types.py +++ b/workos/utils/um_provider_types.py @@ -1,9 +1,6 @@ -from enum import Enum +from typing import Literal -class UserManagementProviderType(Enum): - AuthKit = "authkit" - AppleOAuth = "AppleOAuth" - GitHubOAuth = "GitHubOAuth" - GoogleOAuth = "GoogleOAuth" - MicrosoftOAuth = "MicrosoftOAuth" +UserManagementProviderType = Literal[ + "authkit", "AppleOAuth", "GitHubOAuth", "GoogleOAuth", "MicrosoftOAuth" +] From 99d3ca7e6a847d88e3f208680baf7ecf003e88f0 Mon Sep 17 00:00:00 2001 From: Matt Dzwonczyk <9063128+mattgd@users.noreply.github.com> Date: Mon, 29 Jul 2024 17:51:56 -0400 Subject: [PATCH 11/18] Add typing for MFA. (#295) --- tests/test_client.py | 12 +++ tests/test_mfa.py | 151 ++++++++++-------------------- tests/utils/test_requests.py | 8 +- workos/client.py | 2 +- workos/mfa.py | 175 +++++++++++------------------------ workos/resources/mfa.py | 121 ++---------------------- workos/utils/request.py | 3 +- 7 files changed, 127 insertions(+), 345 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index 177b86a2..0d2b61af 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -10,6 +10,7 @@ def setup(self): client._audit_logs = None client._directory_sync = None client._events = None + client._mfa = None client._organizations = None client._passwordless = None client._portal = None @@ -28,6 +29,9 @@ def test_initialize_directory_sync(self, set_api_key): def test_initialize_events(self, set_api_key): assert bool(client.events) + def test_initialize_mfa(self, set_api_key): + assert bool(client.mfa) + def test_initialize_organizations(self, set_api_key): assert bool(client.organizations) @@ -88,6 +92,14 @@ def test_initialize_events_missing_api_key(self): assert "api_key" in message + def test_initialize_mfa_missing_api_key(self): + with pytest.raises(ConfigurationException) as ex: + client.mfa + + message = str(ex) + + assert "api_key" in message + def test_initialize_organizations_missing_api_key(self): with pytest.raises(ConfigurationException) as ex: client.organizations diff --git a/tests/test_mfa.py b/tests/test_mfa.py index d34f5298..153620dc 100644 --- a/tests/test_mfa.py +++ b/tests/test_mfa.py @@ -1,11 +1,16 @@ from workos.mfa import Mfa import pytest +from workos.utils.http_client import SyncHTTPClient + class TestMfa(object): @pytest.fixture(autouse=True) def setup(self, set_api_key): - self.mfa = Mfa() + self.http_client = SyncHTTPClient( + base_url="https://api.workos.test", version="test" + ) + self.mfa = Mfa(http_client=self.http_client) @pytest.fixture def mock_enroll_factor_no_type(self): @@ -44,6 +49,7 @@ def mock_enroll_factor_response_sms(self): "updated_at": "2022-02-15T15:14:19.392Z", "type": "sms", "sms": {"phone_number": "+19204703484"}, + "user_id": None, } @pytest.fixture @@ -55,10 +61,13 @@ def mock_enroll_factor_response_totp(self): "updated_at": "2022-02-15T15:14:19.392Z", "type": "totp", "totp": { + "issuer": "FooCorp", + "user": "test@example.com", "qr_code": "data:image/png;base64,{base64EncodedPng}", "secret": "NAGCCFS3EYRB422HNAKAKY3XDUORMSRF", "uri": "otpauth://totp/FooCorp:alan.turing@foo-corp.com?secret=NAGCCFS3EYRB422HNAKAKY3XDUORMSRF&issuer=FooCorp", }, + "user_id": None, } @pytest.fixture @@ -70,6 +79,7 @@ def mock_challenge_factor_response(self): "updated_at": "2022-02-15T15:26:53.274Z", "expires_at": "2022-02-15T15:36:53.279Z", "authentication_factor_id": "auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ", + "code": None, } @pytest.fixture @@ -82,22 +92,11 @@ def mock_verify_challenge_response(self): "updated_at": "2022-02-15T15:26:53.274Z", "expires_at": "2022-02-15T15:36:53.279Z", "authentication_factor_id": "auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ", + "code": None, }, - "valid": "true", + "valid": True, } - def test_enroll_factor_no_type(self, mock_enroll_factor_no_type): - with pytest.raises(ValueError) as err: - self.mfa.enroll_factor(type=mock_enroll_factor_no_type) - assert "Incomplete arguments. Need to specify a type of factor" in str( - err.value - ) - - def test_enroll_factor_incorrect_type(self, mock_enroll_factor_incorrect_type): - with pytest.raises(ValueError) as err: - self.mfa.enroll_factor(type=mock_enroll_factor_incorrect_type) - assert "Type parameter must be either 'sms' or 'totp'" in str(err.value) - def test_enroll_factor_totp_no_issuer(self, mock_enroll_factor_totp_payload): with pytest.raises(ValueError) as err: self.mfa.enroll_factor( @@ -133,122 +132,66 @@ def test_enroll_factor_sms_no_phone_number(self, mock_enroll_factor_sms_payload) ) def test_enroll_factor_sms_success( - self, mock_enroll_factor_response_sms, mock_request_method + self, mock_enroll_factor_response_sms, mock_http_client_with_response ): - mock_request_method("post", mock_enroll_factor_response_sms, 200) - enroll_factor = self.mfa.enroll_factor("sms", None, None, 9204448888) - assert enroll_factor == mock_enroll_factor_response_sms + mock_http_client_with_response( + self.http_client, mock_enroll_factor_response_sms, 200 + ) + enroll_factor = self.mfa.enroll_factor("sms", None, None, "9204448888") + assert enroll_factor.dict() == mock_enroll_factor_response_sms def test_enroll_factor_totp_success( - self, mock_enroll_factor_response_totp, mock_request_method + self, mock_enroll_factor_response_totp, mock_http_client_with_response ): - mock_request_method("post", mock_enroll_factor_response_totp, 200) + mock_http_client_with_response( + self.http_client, mock_enroll_factor_response_totp, 200 + ) enroll_factor = self.mfa.enroll_factor( - "totp", "testytest", "berniesanders", None + "totp", totp_issuer="testissuer", totp_user="testuser" ) - assert enroll_factor == mock_enroll_factor_response_totp - - def test_get_factor_no_id(self): - with pytest.raises(ValueError) as err: - self.mfa.delete_factor(authentication_factor_id=None) - assert "Incomplete arguments. Need to specify a factor ID." in str(err.value) + assert enroll_factor.dict() == mock_enroll_factor_response_totp def test_get_factor_totp_success( - self, mock_enroll_factor_response_totp, mock_request_method + self, mock_enroll_factor_response_totp, mock_http_client_with_response ): - mock_request_method("get", mock_enroll_factor_response_totp, 200) + mock_http_client_with_response( + self.http_client, mock_enroll_factor_response_totp, 200 + ) response = self.mfa.get_factor(mock_enroll_factor_response_totp["id"]) - assert response == mock_enroll_factor_response_totp + assert response.dict() == mock_enroll_factor_response_totp def test_get_factor_sms_success( - self, mock_enroll_factor_response_sms, mock_request_method + self, mock_enroll_factor_response_sms, mock_http_client_with_response ): - mock_request_method("get", mock_enroll_factor_response_sms, 200) + mock_http_client_with_response( + self.http_client, mock_enroll_factor_response_sms, 200 + ) response = self.mfa.get_factor(mock_enroll_factor_response_sms["id"]) - assert response == mock_enroll_factor_response_sms - - def test_delete_factor_no_id(self): - with pytest.raises(ValueError) as err: - self.mfa.delete_factor(authentication_factor_id=None) - assert "Incomplete arguments. Need to specify a factor ID." in str(err.value) + assert response.dict() == mock_enroll_factor_response_sms - def test_delete_factor_success(self, mock_request_method): - mock_request_method("delete", None, 200) + def test_delete_factor_success(self, mock_http_client_with_response): + mock_http_client_with_response(self.http_client, None, 200) response = self.mfa.delete_factor("auth_factor_01FZ4TS14D1PHFNZ9GF6YD8M1F") assert response == None - def test_challenge_factor_no_id(self, mock_challenge_factor_payload): - with pytest.raises(ValueError) as err: - self.mfa.challenge_factor( - authentication_factor_id=None, - sms_template=mock_challenge_factor_payload[1], - ) - assert ( - "Incomplete arguments: 'authentication_factor_id' is a required parameter" - in str(err.value) - ) - def test_challenge_success( - self, mock_challenge_factor_response, mock_request_method + self, mock_challenge_factor_response, mock_http_client_with_response ): - mock_request_method("post", mock_challenge_factor_response, 200) + mock_http_client_with_response( + self.http_client, mock_challenge_factor_response, 200 + ) challenge_factor = self.mfa.challenge_factor( "auth_factor_01FXNWW32G7F3MG8MYK5D1HJJM" ) - assert challenge_factor == mock_challenge_factor_response + assert challenge_factor.dict() == mock_challenge_factor_response - def test_verify_factor_no_id(self, mock_verify_challenge_payload): - with pytest.raises(ValueError) as err: - self.mfa.verify_factor( - authentication_challenge_id=None, code=mock_verify_challenge_payload[1] - ) - assert ( - "Incomplete arguments: 'authentication_challenge_id' and 'code' are required parameters" - in str(err.value) - ) - - def test_verify_factor_no_code(self, mock_verify_challenge_payload): - with pytest.raises(ValueError) as err: - self.mfa.verify_factor( - authentication_challenge_id=mock_verify_challenge_payload[0], code=None - ) - assert ( - "Incomplete arguments: 'authentication_challenge_id' and 'code' are required parameters" - in str(err.value) - ) - - def test_verify_factor_success( - self, mock_verify_challenge_response, mock_request_method + def test_verify_success( + self, mock_verify_challenge_response, mock_http_client_with_response ): - mock_request_method("post", mock_verify_challenge_response, 200) - verify_factor = self.mfa.verify_factor( - "auth_challenge_01FXNXH8Y2K3YVWJ10P139A6DT", "093647" + mock_http_client_with_response( + self.http_client, mock_verify_challenge_response, 200 ) - assert verify_factor == mock_verify_challenge_response - - def test_verify_challenge_no_id(self, mock_verify_challenge_payload): - with pytest.raises(ValueError) as err: - self.mfa.verify_challenge( - authentication_challenge_id=None, code=mock_verify_challenge_payload[1] - ) - assert ( - "Incomplete arguments: 'authentication_challenge_id' and 'code' are required parameters" - in str(err.value) - ) - - def test_verify_challenge_no_code(self, mock_verify_challenge_payload): - with pytest.raises(ValueError) as err: - self.mfa.verify_challenge( - authentication_challenge_id=mock_verify_challenge_payload[0], code=None - ) - assert ( - "Incomplete arguments: 'authentication_challenge_id' and 'code' are required parameters" - in str(err.value) - ) - - def test_verify_success(self, mock_verify_challenge_response, mock_request_method): - mock_request_method("post", mock_verify_challenge_response, 200) verify_challenge = self.mfa.verify_challenge( "auth_challenge_01FXNXH8Y2K3YVWJ10P139A6DT", "093647" ) - assert verify_challenge == mock_verify_challenge_response + assert verify_challenge.dict() == mock_verify_challenge_response diff --git a/tests/utils/test_requests.py b/tests/utils/test_requests.py index fea03e0f..4bbef560 100644 --- a/tests/utils/test_requests.py +++ b/tests/utils/test_requests.py @@ -151,8 +151,6 @@ def test_request_parses_json_when_encoding_in_content_type( assert RequestHelper().request("ok_place") == {"foo": "bar"} def test_build_url(self): - assert RequestHelper().build_parameterized_url("a/b/c") == "a/b/c" - assert RequestHelper().build_parameterized_url("a/{b}/c", b="b") == "a/b/c" - assert ( - RequestHelper().build_parameterized_url("a/{b}/c", b="test") == "a/test/c" - ) + assert RequestHelper.build_parameterized_url("a/b/c") == "a/b/c" + assert RequestHelper.build_parameterized_url("a/{b}/c", b="b") == "a/b/c" + assert RequestHelper.build_parameterized_url("a/{b}/c", b="test") == "a/test/c" diff --git a/workos/client.py b/workos/client.py index 9c4e7405..61ef5a49 100644 --- a/workos/client.py +++ b/workos/client.py @@ -73,7 +73,7 @@ def webhooks(self): @property def mfa(self): if not getattr(self, "_mfa", None): - self._mfa = Mfa() + self._mfa = Mfa(self._http_client) return self._mfa @property diff --git a/workos/mfa.py b/workos/mfa.py index 106eb446..c0f974f2 100644 --- a/workos/mfa.py +++ b/workos/mfa.py @@ -1,64 +1,62 @@ -from typing import Protocol -from warnings import warn +from typing import Optional, Protocol import workos +from workos.utils.http_client import SyncHTTPClient from workos.utils.request import ( - RequestHelper, REQUEST_METHOD_POST, REQUEST_METHOD_DELETE, REQUEST_METHOD_GET, + RequestHelper, ) from workos.utils.validation import MFA_MODULE, validate_settings from workos.resources.mfa import ( - WorkOSAuthenticationFactorSms, - WorkOSAuthenticationFactorTotp, - WorkOSChallenge, - WorkOSChallengeVerification, + AuthenticationChallenge, + AuthenticationChallengeVerificationResponse, + AuthenticationFactor, + AuthenticationFactorSms, + AuthenticationFactorTotp, + EnrollAuthenticationFactorType, ) class MFAModule(Protocol): def enroll_factor( self, - type=None, - totp_issuer=None, - totp_user=None, - phone_number=None, - ) -> dict: ... + type: EnrollAuthenticationFactorType, + totp_issuer: Optional[str] = None, + totp_user: Optional[str] = None, + phone_number: Optional[str] = None, + ) -> AuthenticationFactor: ... - def get_factor(self, authentication_factor_id=None) -> dict: ... + def get_factor(self, authentication_factor_id: str) -> AuthenticationFactor: ... - def delete_factor(self, authentication_factor_id=None) -> None: ... + def delete_factor(self, authentication_factor_id: str) -> None: ... def challenge_factor( - self, authentication_factor_id=None, sms_template=None - ) -> dict: ... - - def verify_factor(self, authentication_challenge_id=None, code=None) -> dict: ... + self, authentication_factor_id: str, sms_template: Optional[str] = None + ) -> AuthenticationChallenge: ... - def verify_challenge(self, authentication_challenge_id=None, code=None) -> dict: ... + def verify_challenge( + self, authentication_challenge_id: str, code: str + ) -> AuthenticationChallengeVerificationResponse: ... class Mfa(MFAModule): """Methods to assist in creating, challenging, and verifying Authentication Factors through the WorkOS MFA service.""" - @validate_settings(MFA_MODULE) - def __init__(self): - pass + _http_client: SyncHTTPClient - @property - def request_helper(self): - if not getattr(self, "_request_helper", None): - self._request_helper = RequestHelper() - return self._request_helper + @validate_settings(MFA_MODULE) + def __init__(self, http_client: SyncHTTPClient): + self._http_client = http_client def enroll_factor( self, - type=None, - totp_issuer=None, - totp_user=None, - phone_number=None, - ): + type: EnrollAuthenticationFactorType, + totp_issuer: Optional[str] = None, + totp_user: Optional[str] = None, + phone_number: Optional[str] = None, + ) -> AuthenticationFactor: """ Defines the type of MFA authorization factor to be used. Possible values are sms or totp. @@ -68,7 +66,7 @@ def enroll_factor( totp_user (str) - email of user phone_number (str) - phone number of the user - Returns: Dict containing the authentication factor information. + Returns: AuthenticationFactor """ params = { @@ -78,18 +76,7 @@ def enroll_factor( "phone_number": phone_number, } - if type is None: - raise ValueError("Incomplete arguments. Need to specify a type of factor") - - if type not in ["sms", "totp"]: - raise ValueError("Type parameter must be either 'sms' or 'totp'") - - if ( - type == "totp" - and totp_issuer is None - or type == "totp" - and totp_user is None - ): + if type == "totp" and (totp_issuer is None or totp_user is None): raise ValueError( "Incomplete arguments. Need to specify both totp_issuer and totp_user when type is totp" ) @@ -99,7 +86,7 @@ def enroll_factor( "Incomplete arguments. Need to specify phone_number when type is sms" ) - response = self.request_helper.request( + response = self._http_client.request( "auth/factors/enroll", method=REQUEST_METHOD_POST, params=params, @@ -107,16 +94,11 @@ def enroll_factor( ) if type == "totp": - return WorkOSAuthenticationFactorTotp.construct_from_response( - response - ).to_dict() + return AuthenticationFactorTotp.model_validate(response) - return WorkOSAuthenticationFactorSms.construct_from_response(response).to_dict() + return AuthenticationFactorSms.model_validate(response) - def get_factor( - self, - authentication_factor_id=None, - ): + def get_factor(self, authentication_factor_id: str) -> AuthenticationFactor: """ Returns an authorization factor from its ID. @@ -126,11 +108,8 @@ def get_factor( Returns: Dict containing the authentication factor information. """ - if authentication_factor_id is None: - raise ValueError("Incomplete arguments. Need to specify a factor ID") - - response = self.request_helper.request( - self.request_helper.build_parameterized_url( + response = self._http_client.request( + RequestHelper.build_parameterized_url( "auth/factors/{authentication_factor_id}", authentication_factor_id=authentication_factor_id, ), @@ -139,16 +118,11 @@ def get_factor( ) if response["type"] == "totp": - return WorkOSAuthenticationFactorTotp.construct_from_response( - response - ).to_dict() + return AuthenticationFactorTotp.model_validate(response) - return WorkOSAuthenticationFactorSms.construct_from_response(response).to_dict() + return AuthenticationFactorSms.model_validate(response) - def delete_factor( - self, - authentication_factor_id=None, - ): + def delete_factor(self, authentication_factor_id: str) -> None: """ Deletes an MFA authorization factor. @@ -158,11 +132,8 @@ def delete_factor( Returns: Does not provide a response. """ - if authentication_factor_id is None: - raise ValueError("Incomplete arguments. Need to specify a factor ID.") - - return self.request_helper.request( - self.request_helper.build_parameterized_url( + self._http_client.request( + RequestHelper.build_parameterized_url( "auth/factors/{authentication_factor_id}", authentication_factor_id=authentication_factor_id, ), @@ -172,9 +143,9 @@ def delete_factor( def challenge_factor( self, - authentication_factor_id=None, - sms_template=None, - ): + authentication_factor_id: str, + sms_template: Optional[str] = None, + ) -> AuthenticationChallenge: """ Initiates the authentication process for the newly created MFA authorization factor, referred to as a challenge. @@ -189,13 +160,8 @@ def challenge_factor( "sms_template": sms_template, } - if authentication_factor_id is None: - raise ValueError( - "Incomplete arguments: 'authentication_factor_id' is a required parameter" - ) - - response = self.request_helper.request( - self.request_helper.build_parameterized_url( + response = self._http_client.request( + RequestHelper.build_parameterized_url( "auth/factors/{factor_id}/challenge", factor_id=authentication_factor_id ), method=REQUEST_METHOD_POST, @@ -203,37 +169,11 @@ def challenge_factor( token=workos.api_key, ) - return WorkOSChallenge.construct_from_response(response).to_dict() - - def verify_factor( - self, - authentication_challenge_id=None, - code=None, - ): - """ - Verifies the one time password provided by the end-user. - - Deprecated: Please use `verify_challenge` instead. - - Kwargs: - authentication_challenge_id (str) - The ID of the authentication challenge that provided the user the verification code. - code (str) - The verification code sent to and provided by the end user. - - Returns: Dict containing the challenge factor details. - """ - - warn( - "'verify_factor' is deprecated. Please use 'verify_challenge' instead.", - DeprecationWarning, - ) - - return self.verify_challenge(authentication_challenge_id, code) + return AuthenticationChallenge.model_validate(response) def verify_challenge( - self, - authentication_challenge_id=None, - code=None, - ): + self, authentication_challenge_id: str, code: str + ) -> AuthenticationChallengeVerificationResponse: """ Verifies the one time password provided by the end-user. @@ -241,20 +181,15 @@ def verify_challenge( authentication_challenge_id (str) - The ID of the authentication challenge that provided the user the verification code. code (str) - The verification code sent to and provided by the end user. - Returns: Dict containing the challenge factor details. + Returns: AuthenticationChallengeVerificationResponse containing the challenge factor details. """ params = { "code": code, } - if authentication_challenge_id is None or code is None: - raise ValueError( - "Incomplete arguments: 'authentication_challenge_id' and 'code' are required parameters" - ) - - response = self.request_helper.request( - self.request_helper.build_parameterized_url( + response = self._http_client.request( + RequestHelper.build_parameterized_url( "auth/challenges/{challenge_id}/verify", challenge_id=authentication_challenge_id, ), @@ -263,4 +198,4 @@ def verify_challenge( token=workos.api_key, ) - return WorkOSChallengeVerification.construct_from_response(response).to_dict() + return AuthenticationChallengeVerificationResponse.model_validate(response) diff --git a/workos/resources/mfa.py b/workos/resources/mfa.py index f0d0e5d1..4705ef59 100644 --- a/workos/resources/mfa.py +++ b/workos/resources/mfa.py @@ -1,5 +1,4 @@ from typing import Literal, Optional, Union -from workos.resources.base import WorkOSBaseResource from workos.resources.workos_model import WorkOSModel @@ -8,6 +7,9 @@ AuthenticationFactorType = Literal[ "generic_otp", SmsAuthenticationFactorType, TotpAuthenticationFactorType ] +EnrollAuthenticationFactorType = Literal[ + SmsAuthenticationFactorType, TotpAuthenticationFactorType +] class TotpFactor(WorkOSModel): @@ -31,36 +33,6 @@ class SmsFactor(WorkOSModel): phone_number: str -class WorkOSAuthenticationFactorTotp(WorkOSBaseResource): - """Representation of a MFA Authentication Factor Response as returned by WorkOS through the MFA feature. - - Attributes: - OBJECT_FIELDS (list): List of fields a WorkOSAuthenticationFactor is comprised of. - """ - - OBJECT_FIELDS = [ - "object", - "id", - "created_at", - "updated_at", - "type", - "totp", - ] - - @classmethod - def construct_from_response(cls, response): - enroll_factor_response = super( - WorkOSAuthenticationFactorTotp, cls - ).construct_from_response(response) - - return enroll_factor_response - - def to_dict(self): - challenge_response_dict = super(WorkOSAuthenticationFactorTotp, self).to_dict() - - return challenge_response_dict - - class AuthenticationFactorBase(WorkOSModel): """Representation of a MFA Authentication Factor Response as returned by WorkOS through the MFA feature.""" @@ -89,66 +61,6 @@ class AuthenticationFactorSms(AuthenticationFactorBase): AuthenticationFactor = Union[AuthenticationFactorTotp, AuthenticationFactorSms] -class WorkOSAuthenticationFactorSms(WorkOSBaseResource): - """Representation of a MFA Authentication Factor Response as returned by WorkOS through the MFA feature. - - Attributes: - OBJECT_FIELDS (list): List of fields a WorkOSAuthenticationFactor is comprised of. - """ - - OBJECT_FIELDS = [ - "object", - "id", - "created_at", - "updated_at", - "type", - "sms", - ] - - @classmethod - def construct_from_response(cls, response): - enroll_factor_response = super( - WorkOSAuthenticationFactorSms, cls - ).construct_from_response(response) - - return enroll_factor_response - - def to_dict(self): - challenge_response_dict = super(WorkOSAuthenticationFactorSms, self).to_dict() - - return challenge_response_dict - - -class WorkOSChallenge(WorkOSBaseResource): - """Representation of a MFA Challenge Response as returned by WorkOS through the MFA feature. - - Attributes: - OBJECT_FIELDS (list): List of fields a WorkOSChallenge is comprised of. - """ - - OBJECT_FIELDS = [ - "object", - "id", - "created_at", - "updated_at", - "expires_at", - "authentication_factor_id", - ] - - @classmethod - def construct_from_response(cls, response): - challenge_response = super(WorkOSChallenge, cls).construct_from_response( - response - ) - - return challenge_response - - def to_dict(self): - challenge_response_dict = super(WorkOSChallenge, self).to_dict() - - return challenge_response_dict - - class AuthenticationChallenge(WorkOSModel): """Representation of a MFA Challenge Response as returned by WorkOS through the MFA feature.""" @@ -168,27 +80,8 @@ class AuthenticationFactorTotpAndChallengeResponse(WorkOSModel): authentication_challenge: AuthenticationChallenge -class WorkOSChallengeVerification(WorkOSBaseResource): - """Representation of a MFA Challenge Verification Response as returned by WorkOS through the MFA feature. - - Attributes: - OBJECT_FIELDS (list): List of fields a WorkOSChallengeVerification is comprised of. - """ - - OBJECT_FIELDS = [ - "challenge", - "valid", - ] - - @classmethod - def construct_from_response(cls, response): - verification_response = super( - WorkOSChallengeVerification, cls - ).construct_from_response(response) - - return verification_response - - def to_dict(self): - verification_response_dict = super(WorkOSChallengeVerification, self).to_dict() +class AuthenticationChallengeVerificationResponse(WorkOSModel): + """Representation of a WorkOS MFA Challenge Verification Response.""" - return verification_response_dict + challenge: AuthenticationChallenge + valid: bool diff --git a/workos/utils/request.py b/workos/utils/request.py index f287e7e7..f621b7a7 100644 --- a/workos/utils/request.py +++ b/workos/utils/request.py @@ -48,7 +48,8 @@ def set_base_api_url(self, base_api_url): def generate_api_url(self, path): return self.base_api_url.format(path) - def build_parameterized_url(self, url, **params): + @classmethod + def build_parameterized_url(cls, url, **params): escaped_params = {k: urllib.parse.quote(str(v)) for k, v in params.items()} return url.format(**escaped_params) From 40ac18c0ce5f16d123f0535e4da56512e7a29ee9 Mon Sep 17 00:00:00 2001 From: Matt Dzwonczyk <9063128+mattgd@users.noreply.github.com> Date: Mon, 29 Jul 2024 17:55:36 -0400 Subject: [PATCH 12/18] Add typing for Audit Logs APIs (#297) --- tests/test_audit_logs.py | 176 +++++++++++++------------- workos/audit_logs.py | 131 ++++++++----------- workos/client.py | 2 +- workos/resources/audit_logs.py | 52 ++++++++ workos/resources/audit_logs_export.py | 28 ---- 5 files changed, 191 insertions(+), 198 deletions(-) create mode 100644 workos/resources/audit_logs.py delete mode 100644 workos/resources/audit_logs_export.py diff --git a/tests/test_audit_logs.py b/tests/test_audit_logs.py index a8e79bd7..45e359f8 100644 --- a/tests/test_audit_logs.py +++ b/tests/test_audit_logs.py @@ -1,27 +1,53 @@ from datetime import datetime -import json -from requests import Response import pytest -import workos from workos.audit_logs import AuditLogs from workos.exceptions import AuthenticationException, BadRequestException -from workos.resources.audit_logs_export import WorkOSAuditLogExport +from workos.resources.audit_logs import AuditLogEvent +from workos.utils.http_client import SyncHTTPClient class _TestSetup: @pytest.fixture(autouse=True) def setup(self, set_api_key): - self.audit_logs = AuditLogs() + self.http_client = SyncHTTPClient( + base_url="https://api.workos.test", version="test" + ) + self.audit_logs = AuditLogs(http_client=self.http_client) + + @pytest.fixture + def mock_audit_log_event(self) -> AuditLogEvent: + return { + "action": "document.updated", + "occurred_at": datetime.now().isoformat(), + "actor": { + "id": "user_1", + "name": "Jon Smith", + "type": "user", + }, + "targets": [ + { + "id": "document_39127", + "type": "document", + }, + ], + "context": { + "location": "192.0.0.8", + "user_agent": "Firefox", + }, + "metadata": { + "successful": True, + }, + } class TestAuditLogs: class TestCreateEvent(_TestSetup): - def test_succeeds(self, capture_and_mock_request): + def test_succeeds(self, capture_and_mock_http_client_request): organization_id = "org_123456789" - event = { + event: AuditLogEvent = { "action": "document.updated", "occurred_at": datetime.now().isoformat(), "actor": { @@ -44,7 +70,11 @@ def test_succeeds(self, capture_and_mock_request): }, } - _, request_kwargs = capture_and_mock_request("post", {"success": True}, 200) + request_kwargs = capture_and_mock_http_client_request( + http_client=self.http_client, + response_dict={"success": True}, + status_code=200, + ) response = self.audit_logs.create_event( organization_id, event, "test_123456" @@ -56,66 +86,50 @@ def test_succeeds(self, capture_and_mock_request): } assert response is None - def test_sends_idempotency_key(self, capture_and_mock_request): + def test_sends_idempotency_key( + self, mock_audit_log_event, capture_and_mock_http_client_request + ): idempotency_key = "test_123456789" organization_id = "org_123456789" - event = { - "action": "document.updated", - "occurred_at": datetime.now().isoformat(), - "actor": { - "id": "user_1", - "name": "Jon Smith", - "type": "user", - }, - "targets": [ - { - "id": "document_39127", - "type": "document", - }, - ], - "context": { - "location": "192.0.0.8", - "user_agent": "Firefox", - }, - "metadata": { - "successful": True, - }, - } - - _, request_kwargs = capture_and_mock_request("post", {"success": True}, 200) + request_kwargs = capture_and_mock_http_client_request( + self.http_client, {"success": True}, 200 + ) response = self.audit_logs.create_event( - organization_id, event, idempotency_key + organization_id, mock_audit_log_event, idempotency_key ) assert request_kwargs["headers"]["idempotency-key"] == idempotency_key assert response is None - def test_throws_unauthorized_excpetion(self, mock_request_method): + def test_throws_unauthorized_exception( + self, mock_audit_log_event, mock_http_client_with_response + ): organization_id = "org_123456789" - event = {"any_event": "event"} - mock_request_method( - "post", + mock_http_client_with_response( + self.http_client, {"message": "Unauthorized"}, 401, {"X-Request-ID": "a-request-id"}, ) with pytest.raises(AuthenticationException) as excinfo: - self.audit_logs.create_event(organization_id, event) + self.audit_logs.create_event(organization_id, mock_audit_log_event) assert "(message=Unauthorized, request_id=a-request-id)" == str( excinfo.value ) - def test_throws_badrequest_excpetion(self, mock_request_method): + def test_throws_badrequest_excpetion( + self, mock_audit_log_event, mock_http_client_with_response + ): organization_id = "org_123456789" event = {"any_event": "any_event"} - mock_request_method( - "post", + mock_http_client_with_response( + self.http_client, { "message": "Audit Log could not be processed due to missing or incorrect data.", "code": "invalid_audit_log", @@ -125,7 +139,7 @@ def test_throws_badrequest_excpetion(self, mock_request_method): ) with pytest.raises(BadRequestException) as excinfo: - self.audit_logs.create_event(organization_id, event) + self.audit_logs.create_event(organization_id, mock_audit_log_event) assert excinfo.code == "invalid_audit_log" assert excinfo.errors == ["error in a field"] assert ( @@ -134,39 +148,35 @@ def test_throws_badrequest_excpetion(self, mock_request_method): ) class TestCreateExport(_TestSetup): - def test_succeeds(self, mock_request_method): + def test_succeeds(self, mock_http_client_with_response): organization_id = "org_123456789" - range_start = datetime.now().isoformat - range_end = datetime.now().isoformat + now = datetime.now().isoformat() + range_start = now + range_end = now expected_payload = { "object": "audit_log_export", "id": "audit_log_export_1234", "state": "pending", "url": None, - "created_at": datetime.now().isoformat, - "updated_at": datetime.now().isoformat, + "created_at": now, + "updated_at": now, } - mock_request_method("post", expected_payload, 201) + mock_http_client_with_response(self.http_client, expected_payload, 201) response = self.audit_logs.create_export( organization_id, range_start, range_end ) - assert ( - response.to_dict() - == WorkOSAuditLogExport.construct_from_response( - expected_payload - ).to_dict() - ) + assert response.dict() == expected_payload - def test_succeeds_with_additional_filters(self, mock_request_method): + def test_succeeds_with_additional_filters(self, mock_http_client_with_response): + now = datetime.now().isoformat() organization_id = "org_123456789" - range_start = datetime.now().isoformat - range_end = datetime.now().isoformat + range_start = now + range_end = now actions = ["foo", "bar"] - actors = ["Jon", "Smith"] actor_names = ["Jon", "Smith"] actor_ids = ["user_foo", "user_bar"] targets = ["user", "team"] @@ -176,16 +186,15 @@ def test_succeeds_with_additional_filters(self, mock_request_method): "id": "audit_log_export_1234", "state": "pending", "url": None, - "created_at": datetime.now().isoformat, - "updated_at": datetime.now().isoformat, + "created_at": now, + "updated_at": now, } - mock_request_method("post", expected_payload, 201) + mock_http_client_with_response(self.http_client, expected_payload, 201) response = self.audit_logs.create_export( actions=actions, - actors=actors, - organization=organization_id, + organization_id=organization_id, range_end=range_end, range_start=range_start, targets=targets, @@ -193,20 +202,15 @@ def test_succeeds_with_additional_filters(self, mock_request_method): actor_ids=actor_ids, ) - assert ( - response.to_dict() - == WorkOSAuditLogExport.construct_from_response( - expected_payload - ).to_dict() - ) + assert response.dict() == expected_payload - def test_throws_unauthorized_excpetion(self, mock_request_method): + def test_throws_unauthorized_excpetion(self, mock_http_client_with_response): organization_id = "org_123456789" - range_start = datetime.now().isoformat - range_end = datetime.now().isoformat + range_start = datetime.now().isoformat() + range_end = datetime.now().isoformat() - mock_request_method( - "post", + mock_http_client_with_response( + self.http_client, {"message": "Unauthorized"}, 401, {"X-Request-ID": "a-request-id"}, @@ -219,32 +223,28 @@ def test_throws_unauthorized_excpetion(self, mock_request_method): ) class TestGetExport(_TestSetup): - def test_succeeds(self, mock_request_method): + def test_succeeds(self, mock_http_client_with_response): + now = datetime.now().isoformat() expected_payload = { "object": "audit_log_export", "id": "audit_log_export_1234", "state": "pending", "url": None, - "created_at": datetime.now().isoformat, - "updated_at": datetime.now().isoformat, + "created_at": now, + "updated_at": now, } - mock_request_method("get", expected_payload, 200) + mock_http_client_with_response(self.http_client, expected_payload, 200) response = self.audit_logs.get_export( expected_payload["id"], ) - assert ( - response.to_dict() - == WorkOSAuditLogExport.construct_from_response( - expected_payload - ).to_dict() - ) + assert response.dict() == expected_payload - def test_throws_unauthorized_excpetion(self, mock_request_method): - mock_request_method( - "get", + def test_throws_unauthorized_excpetion(self, mock_http_client_with_response): + mock_http_client_with_response( + self.http_client, {"message": "Unauthorized"}, 401, {"X-Request-ID": "a-request-id"}, diff --git a/workos/audit_logs.py b/workos/audit_logs.py index 9af0267d..43c9a52e 100644 --- a/workos/audit_logs.py +++ b/workos/audit_logs.py @@ -1,9 +1,9 @@ -from typing import Optional, Protocol -from warnings import warn +from typing import List, Optional, Protocol import workos -from workos.resources.audit_logs_export import WorkOSAuditLogExport -from workos.utils.request import RequestHelper, REQUEST_METHOD_GET, REQUEST_METHOD_POST +from workos.resources.audit_logs import AuditLogEvent, AuditLogExport +from workos.utils.http_client import SyncHTTPClient +from workos.utils.request import REQUEST_METHOD_GET, REQUEST_METHOD_POST from workos.utils.validation import AUDIT_LOGS_MODULE, validate_settings EVENTS_PATH = "audit_logs/events" @@ -12,70 +12,55 @@ class AuditLogsModule(Protocol): def create_event( - self, organization: str, event: dict, idempotency_key: Optional[str] = None + self, + organization_id: str, + event: AuditLogEvent, + idempotency_key: Optional[str] = None, ) -> None: ... def create_export( self, - organization, - range_start, - range_end, - actions=None, - actors=None, - targets=None, - actor_names=None, - actor_ids=None, - ) -> WorkOSAuditLogExport: ... + organization_id: str, + range_start: str, + range_end: str, + actions: Optional[List[str]] = None, + targets: Optional[List[str]] = None, + actor_names: Optional[List[str]] = None, + actor_ids: Optional[List[str]] = None, + ) -> AuditLogExport: ... - def get_export(self, export_id) -> WorkOSAuditLogExport: ... + def get_export(self, audit_log_export_id: str) -> AuditLogExport: ... class AuditLogs(AuditLogsModule): """Offers methods through the WorkOS Audit Logs service.""" - @validate_settings(AUDIT_LOGS_MODULE) - def __init__(self): - pass + _http_client: SyncHTTPClient - @property - def request_helper(self): - if not getattr(self, "_request_helper", None): - self._request_helper = RequestHelper() - return self._request_helper + @validate_settings(AUDIT_LOGS_MODULE) + def __init__(self, http_client: SyncHTTPClient): + self._http_client = http_client def create_event( - self, organization: str, event: dict, idempotency_key: Optional[str] = None - ): + self, + organization_id: str, + event: AuditLogEvent, + idempotency_key: Optional[str] = None, + ) -> None: """Create an Audit Logs event. Args: organization (str) - Organization's unique identifier - event (dict) - An event object - event[action] (string) - The event action - event[version] (int) - The schema version of the event - event[occurred_at] (datetime) - ISO-8601 datetime of when an event occurred - event[actor] (dict) - Describes the entity that generated the event - event[actor][id] (str) - event[actor][name] (str) - event[actor][type] (str) - event[actor][metadata] (dict) - event[targets] (list[dict]) - List of event targets - event[context] (dict) - Attributes of event context - event[context][location] (str) - event[context][user_agent] (str) - event[metadata] (dict) - Extra metadata + event (AuditLogEvent) - An AuditLogEvent object idempotency_key (str) - Optional idempotency key - - Returns: - boolean: Returns True """ - payload = {"organization_id": organization, "event": event} + payload = {"organization_id": organization_id, "event": event} headers = {} if idempotency_key: headers["idempotency-key"] = idempotency_key - response = self.request_helper.request( + self._http_client.request( EVENTS_PATH, method=REQUEST_METHOD_POST, params=payload, @@ -85,15 +70,14 @@ def create_event( def create_export( self, - organization, - range_start, - range_end, - actions=None, - actors=None, - targets=None, - actor_names=None, - actor_ids=None, - ): + organization_id: str, + range_start: str, + range_end: str, + actions: Optional[List[str]] = None, + targets: Optional[List[str]] = None, + actor_names: Optional[List[str]] = None, + actor_ids: Optional[List[str]] = None, + ) -> AuditLogExport: """Trigger the creation of an export of audit logs. Args: @@ -105,54 +89,39 @@ def create_export( targets (list) - Optional list of targets to filter Returns: - dict: Object that describes the audit log export + AuditLogExport: Object that describes the audit log export """ payload = { - "organization_id": organization, + "actions": actions, + "actor_ids": actor_ids, + "actor_names": actor_names, + "organization_id": organization_id, "range_start": range_start, "range_end": range_end, + "targets": targets, } - if actions: - payload["actions"] = actions - - if actors: - payload["actors"] = actors - warn( - "The 'actors' parameter is deprecated. Please use 'actor_names' instead.", - DeprecationWarning, - ) - - if actor_names: - payload["actor_names"] = actor_names - - if actor_ids: - payload["actor_ids"] = actor_ids - - if targets: - payload["targets"] = targets - - response = self.request_helper.request( + response = self._http_client.request( EXPORTS_PATH, method=REQUEST_METHOD_POST, params=payload, token=workos.api_key, ) - return WorkOSAuditLogExport.construct_from_response(response) + return AuditLogExport.model_validate(response) - def get_export(self, export_id): + def get_export(self, audit_log_export_id: str) -> AuditLogExport: """Retrieve an created export. Returns: - dict: Object that describes the audit log export + AuditLogExport: Object that describes the audit log export """ - response = self.request_helper.request( - "{0}/{1}".format(EXPORTS_PATH, export_id), + response = self._http_client.request( + "{0}/{1}".format(EXPORTS_PATH, audit_log_export_id), method=REQUEST_METHOD_GET, token=workos.api_key, ) - return WorkOSAuditLogExport.construct_from_response(response) + return AuditLogExport.model_validate(response) diff --git a/workos/client.py b/workos/client.py index 61ef5a49..8679b2f4 100644 --- a/workos/client.py +++ b/workos/client.py @@ -31,7 +31,7 @@ def sso(self): @property def audit_logs(self): if not getattr(self, "_audit_logs", None): - self._audit_logs = AuditLogs() + self._audit_logs = AuditLogs(self._http_client) return self._audit_logs @property diff --git a/workos/resources/audit_logs.py b/workos/resources/audit_logs.py new file mode 100644 index 00000000..511ee931 --- /dev/null +++ b/workos/resources/audit_logs.py @@ -0,0 +1,52 @@ +from typing import List, Literal, Optional, TypedDict +from typing_extensions import NotRequired + +from workos.resources.workos_model import WorkOSModel + +AuditLogExportState = Literal["error", "pending", "ready"] + + +class AuditLogExport(WorkOSModel): + """Representation of a WorkOS audit logs export.""" + + object: Literal["audit_log_export"] + id: str + created_at: str + updated_at: str + state: AuditLogExportState + url: Optional[str] = None + + +class AuditLogEventActor(TypedDict): + """Describes the entity that generated the event.""" + + id: str + metadata: NotRequired[dict] + name: NotRequired[str] + type: str + + +class AuditLogEventTarget(TypedDict): + """Describes the entity that was targeted by the event.""" + + id: str + metadata: NotRequired[dict] + name: NotRequired[str] + type: str + + +class AuditLogEventContext(TypedDict): + """Attributes of audit log event context.""" + + location: str + user_agent: NotRequired[str] + + +class AuditLogEvent(TypedDict): + action: str + version: NotRequired[int] + occurred_at: str # ISO-8601 datetime of when an event occurred + actor: AuditLogEventActor + targets: List[AuditLogEventTarget] + context: AuditLogEventContext + metadata: NotRequired[dict] diff --git a/workos/resources/audit_logs_export.py b/workos/resources/audit_logs_export.py deleted file mode 100644 index f9279f96..00000000 --- a/workos/resources/audit_logs_export.py +++ /dev/null @@ -1,28 +0,0 @@ -from workos.resources.base import WorkOSBaseResource -from workos.resources.event_action import WorkOSEventAction - - -class WorkOSAuditLogExport(WorkOSBaseResource): - """Representation of an export as returned by WorkOS through the Audit Logs Create/Get Export feature. - - Attributes: - OBJECT_FIELDS (list): List of fields a WorkOSAuditLogsEvent is comprised of. - """ - - OBJECT_FIELDS = [ - "id", - "object", - "state", - "url", - "created_at", - "updated_at", - ] - - @classmethod - def construct_from_response(cls, response): - export = super(WorkOSAuditLogExport, cls).construct_from_response(response) - return export - - def to_dict(self): - export_dict = super(WorkOSAuditLogExport, self).to_dict() - return export_dict From ffef35e565736682cd8d60176a1e7aadf072f6c4 Mon Sep 17 00:00:00 2001 From: Matt Dzwonczyk <9063128+mattgd@users.noreply.github.com> Date: Mon, 29 Jul 2024 18:35:28 -0400 Subject: [PATCH 13/18] Add typing for portal. (#299) --- tests/test_organizations.py | 75 ++++++++++++++++++++----------------- tests/test_portal.py | 40 +++++++++++--------- workos/client.py | 4 +- workos/organizations.py | 27 ++++++------- workos/portal.py | 41 ++++++++++---------- workos/resources/portal.py | 10 +++++ 6 files changed, 108 insertions(+), 89 deletions(-) create mode 100644 workos/resources/portal.py diff --git a/tests/test_organizations.py b/tests/test_organizations.py index 3eb7bb81..d06ec3fe 100644 --- a/tests/test_organizations.py +++ b/tests/test_organizations.py @@ -2,15 +2,19 @@ import pytest -from tests.utils.list_resource import list_data_to_dicts +from tests.utils.list_resource import list_data_to_dicts, list_response_of from workos.organizations import Organizations from tests.utils.fixtures.mock_organization import MockOrganization +from workos.utils.http_client import SyncHTTPClient class TestOrganizations(object): @pytest.fixture(autouse=True) def setup(self, set_api_key): - self.organizations = Organizations() + self.http_client = SyncHTTPClient( + base_url="https://api.workos.test", version="test" + ) + self.organizations = Organizations(http_client=self.http_client) @pytest.fixture def mock_organization(self): @@ -55,10 +59,15 @@ def mock_organizations_single_page_response(self): @pytest.fixture def mock_organizations_multiple_data_pages(self): - return [MockOrganization(id=str(f"org_{i+1}")).to_dict() for i in range(25)] + organizations_list = [ + MockOrganization(id=str(f"org_{i+1}")).to_dict() for i in range(40) + ] + return list_response_of(data=organizations_list) - def test_list_organizations(self, mock_organizations, mock_request_method): - mock_request_method("get", mock_organizations, 200) + def test_list_organizations( + self, mock_organizations, mock_http_client_with_response + ): + mock_http_client_with_response(self.http_client, mock_organizations, 200) organizations_response = self.organizations.list_organizations() @@ -70,8 +79,8 @@ def to_dict(x): == mock_organizations["data"] ) - def test_get_organization(self, mock_organization, mock_request_method): - mock_request_method("get", mock_organization, 200) + def test_get_organization(self, mock_organization, mock_http_client_with_response): + mock_http_client_with_response(self.http_client, mock_organization, 200) organization = self.organizations.get_organization( organization="organization_id" @@ -80,9 +89,9 @@ def test_get_organization(self, mock_organization, mock_request_method): assert organization.dict() == mock_organization def test_get_organization_by_lookup_key( - self, mock_organization, mock_request_method + self, mock_organization, mock_http_client_with_response ): - mock_request_method("get", mock_organization, 200) + mock_http_client_with_response(self.http_client, mock_organization, 200) organization = self.organizations.get_organization_by_lookup_key( lookup_key="test" @@ -91,9 +100,9 @@ def test_get_organization_by_lookup_key( assert organization.dict() == mock_organization def test_create_organization_with_domain_data( - self, mock_organization, mock_request_method + self, mock_organization, mock_http_client_with_response ): - mock_request_method("post", mock_organization, 201) + mock_http_client_with_response(self.http_client, mock_organization, 201) payload = { "domain_data": [{"domain": "example.com", "state": "verified"}], @@ -104,7 +113,9 @@ def test_create_organization_with_domain_data( assert organization.id == "org_01EHT88Z8J8795GZNQ4ZP1J81T" assert organization.name == "Foo Corporation" - def test_sends_idempotency_key(self, mock_organization, capture_and_mock_request): + def test_sends_idempotency_key( + self, mock_organization, capture_and_mock_http_client_request + ): idempotency_key = "test_123456789" payload = { @@ -112,7 +123,9 @@ def test_sends_idempotency_key(self, mock_organization, capture_and_mock_request "name": "Foo Corporation", } - _, request_kwargs = capture_and_mock_request("post", mock_organization, 200) + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_organization, 200 + ) response = self.organizations.create_organization( **payload, idempotency_key=idempotency_key @@ -122,9 +135,9 @@ def test_sends_idempotency_key(self, mock_organization, capture_and_mock_request assert response.name == "Foo Corporation" def test_update_organization_with_domain_data( - self, mock_organization_updated, mock_request_method + self, mock_organization_updated, mock_http_client_with_response ): - mock_request_method("put", mock_organization_updated, 201) + mock_http_client_with_response(self.http_client, mock_organization_updated, 201) updated_organization = self.organizations.update_organization( organization="org_01EHT88Z8J8795GZNQ4ZP1J81T", @@ -142,10 +155,9 @@ def test_update_organization_with_domain_data( } ] - def test_delete_organization(self, setup, mock_raw_request_method): - mock_raw_request_method( - "delete", - "Accepted", + def test_delete_organization(self, setup, mock_http_client_with_response): + mock_http_client_with_response( + self.http_client, 202, headers={"content-type": "text/plain; charset=utf-8"}, ) @@ -158,9 +170,11 @@ def test_list_organizations_auto_pagination_for_single_page( self, mock_organizations_single_page_response, mock_organizations, - mock_request_method, + mock_http_client_with_response, ): - mock_request_method("get", mock_organizations_single_page_response, 200) + mock_http_client_with_response( + self.http_client, mock_organizations_single_page_response, 200 + ) all_organizations = [] organizations = self.organizations.list_organizations() @@ -176,19 +190,10 @@ def test_list_organizations_auto_pagination_for_single_page( def test_list_organizations_auto_pagination_for_multiple_pages( self, mock_organizations_multiple_data_pages, - mock_pagination_request, + test_sync_auto_pagination, ): - mock_pagination_request("get", mock_organizations_multiple_data_pages, 200) - - all_organizations = [] - organizations = self.organizations.list_organizations() - - for org in organizations.auto_paging_iter(): - all_organizations.append(org) - - assert len(list(all_organizations)) == len( - mock_organizations_multiple_data_pages + test_sync_auto_pagination( + http_client=self.http_client, + list_function=self.organizations.list_organizations, + expected_all_page_data=mock_organizations_multiple_data_pages["data"], ) - assert ( - list_data_to_dicts(all_organizations) - ) == mock_organizations_multiple_data_pages diff --git a/tests/test_portal.py b/tests/test_portal.py index f459f265..8b7a4129 100644 --- a/tests/test_portal.py +++ b/tests/test_portal.py @@ -1,49 +1,55 @@ -import json -from requests import Response - import pytest -import workos from workos.portal import Portal +from workos.utils.http_client import SyncHTTPClient class TestPortal(object): @pytest.fixture(autouse=True) def setup(self, set_api_key): - self.portal = Portal() + self.http_client = SyncHTTPClient( + base_url="https://api.workos.test", version="test" + ) + self.portal = Portal(http_client=self.http_client) @pytest.fixture def mock_portal_link(self): return {"link": "https://id.workos.com/portal/launch?secret=secret"} - def test_generate_link_sso(self, mock_portal_link, mock_request_method): - mock_request_method("post", mock_portal_link, 201) + def test_generate_link_sso(self, mock_portal_link, mock_http_client_with_response): + mock_http_client_with_response(self.http_client, mock_portal_link, 201) response = self.portal.generate_link("sso", "org_01EHQMYV6MBK39QC5PZXHY59C3") - assert response["link"] == "https://id.workos.com/portal/launch?secret=secret" + assert response.link == "https://id.workos.com/portal/launch?secret=secret" - def test_generate_link_dsync(self, mock_portal_link, mock_request_method): - mock_request_method("post", mock_portal_link, 201) + def test_generate_link_dsync( + self, mock_portal_link, mock_http_client_with_response + ): + mock_http_client_with_response(self.http_client, mock_portal_link, 201) response = self.portal.generate_link("dsync", "org_01EHQMYV6MBK39QC5PZXHY59C3") - assert response["link"] == "https://id.workos.com/portal/launch?secret=secret" + assert response.link == "https://id.workos.com/portal/launch?secret=secret" - def test_generate_link_audit_logs(self, mock_portal_link, mock_request_method): - mock_request_method("post", mock_portal_link, 201) + def test_generate_link_audit_logs( + self, mock_portal_link, mock_http_client_with_response + ): + mock_http_client_with_response(self.http_client, mock_portal_link, 201) response = self.portal.generate_link( "audit_logs", "org_01EHQMYV6MBK39QC5PZXHY59C3" ) - assert response["link"] == "https://id.workos.com/portal/launch?secret=secret" + assert response.link == "https://id.workos.com/portal/launch?secret=secret" - def test_generate_link_log_streams(self, mock_portal_link, mock_request_method): - mock_request_method("post", mock_portal_link, 201) + def test_generate_link_log_streams( + self, mock_portal_link, mock_http_client_with_response + ): + mock_http_client_with_response(self.http_client, mock_portal_link, 201) response = self.portal.generate_link( "log_streams", "org_01EHQMYV6MBK39QC5PZXHY59C3" ) - assert response["link"] == "https://id.workos.com/portal/launch?secret=secret" + assert response.link == "https://id.workos.com/portal/launch?secret=secret" diff --git a/workos/client.py b/workos/client.py index 8679b2f4..b2b395d6 100644 --- a/workos/client.py +++ b/workos/client.py @@ -49,7 +49,7 @@ def events(self): @property def organizations(self): if not getattr(self, "_organizations", None): - self._organizations = Organizations() + self._organizations = Organizations(self._http_client) return self._organizations @property @@ -61,7 +61,7 @@ def passwordless(self): @property def portal(self): if not getattr(self, "_portal", None): - self._portal = Portal() + self._portal = Portal(self._http_client) return self._portal @property diff --git a/workos/organizations.py b/workos/organizations.py index d01e872f..584355dd 100644 --- a/workos/organizations.py +++ b/workos/organizations.py @@ -1,9 +1,9 @@ from typing import List, Optional, Protocol import workos +from workos.utils.http_client import SyncHTTPClient from workos.utils.pagination_order import PaginationOrder from workos.utils.request import ( DEFAULT_LIST_RESPONSE_LIMIT, - RequestHelper, REQUEST_METHOD_DELETE, REQUEST_METHOD_GET, REQUEST_METHOD_POST, @@ -55,15 +55,12 @@ def delete_organization(self, organization: str) -> None: ... class Organizations(OrganizationsModule): - @validate_settings(ORGANIZATIONS_MODULE) - def __init__(self): - pass - @property - def request_helper(self): - if not getattr(self, "_request_helper", None): - self._request_helper = RequestHelper() - return self._request_helper + _http_client: SyncHTTPClient + + @validate_settings(ORGANIZATIONS_MODULE) + def __init__(self, http_client: SyncHTTPClient): + self._http_client = http_client def list_organizations( self, @@ -94,7 +91,7 @@ def list_organizations( "domains": domains, } - response = self.request_helper.request( + response = self._http_client.request( ORGANIZATIONS_PATH, method=REQUEST_METHOD_GET, params=list_params, @@ -114,7 +111,7 @@ def get_organization(self, organization: str) -> Organization: Returns: dict: Organization response from WorkOS """ - response = self.request_helper.request( + response = self._http_client.request( "organizations/{organization}".format(organization=organization), method=REQUEST_METHOD_GET, token=workos.api_key, @@ -129,7 +126,7 @@ def get_organization_by_lookup_key(self, lookup_key: str) -> Organization: Returns: dict: Organization response from WorkOS """ - response = self.request_helper.request( + response = self._http_client.request( "organizations/by_lookup_key/{lookup_key}".format(lookup_key=lookup_key), method=REQUEST_METHOD_GET, token=workos.api_key, @@ -154,7 +151,7 @@ def create_organization( "idempotency_key": idempotency_key, } - response = self.request_helper.request( + response = self._http_client.request( ORGANIZATIONS_PATH, method=REQUEST_METHOD_POST, params=params, @@ -175,7 +172,7 @@ def update_organization( "domain_data": domain_data, } - response = self.request_helper.request( + response = self._http_client.request( "organizations/{organization}".format(organization=organization), method=REQUEST_METHOD_PUT, params=params, @@ -190,7 +187,7 @@ def delete_organization(self, organization: str): Args: organization (str): Organization unique identifier """ - return self.request_helper.request( + return self._http_client.request( "organizations/{organization}".format(organization=organization), method=REQUEST_METHOD_DELETE, token=workos.api_key, diff --git a/workos/portal.py b/workos/portal.py index 0d7ab798..9c0611d6 100644 --- a/workos/portal.py +++ b/workos/portal.py @@ -1,7 +1,9 @@ -from typing import Literal, Optional, Protocol +from typing import Optional, Protocol import workos -from workos.utils.request import RequestHelper, REQUEST_METHOD_POST +from workos.resources.portal import PortalLink, PortalLinkIntent +from workos.utils.http_client import SyncHTTPClient +from workos.utils.request import REQUEST_METHOD_POST from workos.utils.validation import PORTAL_MODULE, validate_settings @@ -11,53 +13,52 @@ class PortalModule(Protocol): def generate_link( self, - intent: Literal["audit_logs", "dsync", "log_streams", "sso"], - organization: str, + intent: PortalLinkIntent, + organization_id: str, return_url: Optional[str] = None, success_url: Optional[str] = None, - ) -> dict: ... + ) -> PortalLink: ... class Portal(PortalModule): - @validate_settings(PORTAL_MODULE) - def __init__(self): - pass - @property - def request_helper(self): - if not getattr(self, "_request_helper", None): - self._request_helper = RequestHelper() - return self._request_helper + _http_client: SyncHTTPClient + + @validate_settings(PORTAL_MODULE) + def __init__(self, http_client: SyncHTTPClient): + self._http_client = http_client def generate_link( self, - intent: Literal["audit_logs", "dsync", "log_streams", "sso"], - organization: str, + intent: PortalLinkIntent, + organization_id: str, return_url: Optional[str] = None, success_url: Optional[str] = None, - ): + ) -> PortalLink: """Generate a link to grant access to an organization's Admin Portal Args: intent (str): The access scope for the generated Admin Portal link. Valid values are: ["audit_logs", "dsync", "log_streams", "sso",] - organization (string): The ID of the organization the Admin Portal link will be generated for + organization_id (string): The ID of the organization the Admin Portal link will be generated for Kwargs: return_url (str): The URL that the end user will be redirected to upon exiting the generated Admin Portal. If none is provided, the default redirect link set in your WorkOS Dashboard will be used. (Optional) success_url (str): The URL to which WorkOS will redirect users to upon successfully viewing Audit Logs, setting up Log Streams, Single Sign On or Directory Sync. (Optional) Returns: - str: URL to redirect a User to to access an Admin Portal session + PortalLink: PortalLink object with URL to redirect a User to to access an Admin Portal session """ params = { "intent": intent, - "organization": organization, + "organization": organization_id, "return_url": return_url, "success_url": success_url, } - return self.request_helper.request( + response = self._http_client.request( PORTAL_GENERATE_PATH, method=REQUEST_METHOD_POST, params=params, token=workos.api_key, ) + + return PortalLink.model_validate(response) diff --git a/workos/resources/portal.py b/workos/resources/portal.py new file mode 100644 index 00000000..7f260b8b --- /dev/null +++ b/workos/resources/portal.py @@ -0,0 +1,10 @@ +from typing import Literal +from workos.resources.workos_model import WorkOSModel + +PortalLinkIntent = Literal["audit_logs", "dsync", "log_streams", "sso"] + + +class PortalLink(WorkOSModel): + """Representation of an WorkOS generate portal link response.""" + + link: str From d9af5c6299cca9cdf2effbe901c5ae6226b10bdd Mon Sep 17 00:00:00 2001 From: Matt Dzwonczyk <9063128+mattgd@users.noreply.github.com> Date: Mon, 29 Jul 2024 19:02:36 -0400 Subject: [PATCH 14/18] Add typing for Passwordless APIs. (#298) --- tests/test_passwordless.py | 24 ++++----- workos/client.py | 2 +- workos/passwordless.py | 84 +++++++++++++++++++------------- workos/resources/passwordless.py | 36 ++++---------- 4 files changed, 74 insertions(+), 72 deletions(-) diff --git a/tests/test_passwordless.py b/tests/test_passwordless.py index a1aa2009..75b8e2b1 100644 --- a/tests/test_passwordless.py +++ b/tests/test_passwordless.py @@ -1,16 +1,16 @@ -import json -from requests import Response - import pytest -import workos from workos.passwordless import Passwordless +from workos.utils.http_client import SyncHTTPClient -class TestPasswordless(object): +class TestPasswordless: @pytest.fixture(autouse=True) def setup(self, set_api_key_and_client_id): - self.passwordless = Passwordless() + self.http_client = SyncHTTPClient( + base_url="https://api.workos.test", version="test" + ) + self.passwordless = Passwordless(http_client=self.http_client) @pytest.fixture def mock_passwordless_session(self): @@ -23,24 +23,24 @@ def mock_passwordless_session(self): } def test_create_session_succeeds( - self, mock_passwordless_session, mock_request_method + self, mock_passwordless_session, mock_http_client_with_response ): - mock_request_method("post", mock_passwordless_session, 201) + mock_http_client_with_response(self.http_client, mock_passwordless_session, 201) session_options = { "email": "demo@workos-okta.com", "type": "MagicLink", "expires_in": 300, } - passwordless_session = self.passwordless.create_session(session_options) + passwordless_session = self.passwordless.create_session(**session_options) - assert passwordless_session == mock_passwordless_session + assert passwordless_session.dict() == mock_passwordless_session - def test_get_send_session_succeeds(self, mock_request_method): + def test_get_send_session_succeeds(self, mock_http_client_with_response): response = { "success": True, } - mock_request_method("post", response, 200) + mock_http_client_with_response(self.http_client, response, 200) response = self.passwordless.send_session( "passwordless_session_01EHDAK2BFGWCSZXP9HGZ3VK8C" diff --git a/workos/client.py b/workos/client.py index b2b395d6..40973f02 100644 --- a/workos/client.py +++ b/workos/client.py @@ -55,7 +55,7 @@ def organizations(self): @property def passwordless(self): if not getattr(self, "_passwordless", None): - self._passwordless = Passwordless() + self._passwordless = Passwordless(self._http_client) return self._passwordless @property diff --git a/workos/passwordless.py b/workos/passwordless.py index 26079b42..21a1188a 100644 --- a/workos/passwordless.py +++ b/workos/passwordless.py @@ -1,13 +1,21 @@ -from typing import Literal, Protocol +from typing import Literal, Optional, Protocol import workos -from workos.utils.request import RequestHelper, REQUEST_METHOD_POST +from workos.utils.http_client import SyncHTTPClient +from workos.utils.request import REQUEST_METHOD_POST from workos.utils.validation import PASSWORDLESS_MODULE, validate_settings -from workos.resources.passwordless import WorkOSPasswordlessSession +from workos.resources.passwordless import PasswordlessSession, PasswordlessSessionType class PasswordlessModule(Protocol): - def create_session(self, session_options: dict) -> dict: ... + def create_session( + self, + email: str, + type: PasswordlessSessionType, + redirect_uri: Optional[str] = None, + state: Optional[str] = None, + expires_in: Optional[int] = None, + ) -> PasswordlessSession: ... def send_session(self, session_id: str) -> Literal[True]: ... @@ -15,47 +23,57 @@ def send_session(self, session_id: str) -> Literal[True]: ... class Passwordless(PasswordlessModule): """Offers methods through the WorkOS Passwordless service.""" - @validate_settings(PASSWORDLESS_MODULE) - def __init__(self): - pass - - @property - def request_helper(self): - if not getattr(self, "_request_helper", None): - self._request_helper = RequestHelper() - return self._request_helper + _http_client: SyncHTTPClient - def create_session(self, session_options): + @validate_settings(PASSWORDLESS_MODULE) + def __init__(self, http_client: SyncHTTPClient): + self._http_client = http_client + + def create_session( + self, + email: str, + type: PasswordlessSessionType, + redirect_uri: Optional[str] = None, + state: Optional[str] = None, + expires_in: Optional[int] = None, + ) -> PasswordlessSession: """Create a Passwordless Session. Args: - session_options (dict) - An session options object - session_options[email] (str): The email of the user to authenticate. - session_options[redirect_uri] (str): Optional parameter to - specify the redirect endpoint which will handle the callback - from WorkOS. Defaults to the default Redirect URI in the - WorkOS dashboard. - session_options[state] (str): Optional parameter that the redirect - URI received from WorkOS will contain. The state parameter - can be used to encode arbitrary information to help - restore application state between redirects. - session_options[type] (str): The type of Passwordless Session to - create. Currently, the only supported value is 'MagicLink'. - session_options[expires_in] (int): The number of seconds the Passwordless Session should live before expiring. - This value must be between 900 (15 minutes) and 86400 (24 hours), inclusive. + email (str): The email of the user to authenticate. + redirect_uri (str): Optional parameter to + specify the redirect endpoint which will handle the callback + from WorkOS. Defaults to the default Redirect URI in the + WorkOS dashboard. + state (str): Optional parameter that the redirect + URI received from WorkOS will contain. The state parameter + can be used to encode arbitrary information to help + restore application state between redirects. + type (str): The type of Passwordless Session to + create. Currently, the only supported value is 'MagicLink'. + expires_in (int): The number of seconds the Passwordless Session should live before expiring. + This value must be between 900 (15 minutes) and 86400 (24 hours), inclusive. Returns: - dict: Passwordless Session + PasswordlessSession """ - response = self.request_helper.request( + params = { + "email": email, + "type": type, + "expires_in": expires_in, + "redirect_uri": redirect_uri, + "state": state, + } + + response = self._http_client.request( "passwordless/sessions", method=REQUEST_METHOD_POST, - params=session_options, + params=params, token=workos.api_key, ) - return WorkOSPasswordlessSession.construct_from_response(response).to_dict() + return PasswordlessSession.model_validate(response) def send_session(self, session_id: str) -> Literal[True]: """Send a Passwordless Session via email. @@ -67,7 +85,7 @@ def send_session(self, session_id: str) -> Literal[True]: Returns: boolean: Returns True """ - self.request_helper.request( + self._http_client.request( "passwordless/sessions/{session_id}/send".format(session_id=session_id), method=REQUEST_METHOD_POST, token=workos.api_key, diff --git a/workos/resources/passwordless.py b/workos/resources/passwordless.py index a0e89757..8afd48ec 100644 --- a/workos/resources/passwordless.py +++ b/workos/resources/passwordless.py @@ -1,30 +1,14 @@ -from workos.resources.base import WorkOSBaseResource +from typing import Literal +from workos.resources.workos_model import WorkOSModel +PasswordlessSessionType = Literal["MagicLink"] -class WorkOSPasswordlessSession(WorkOSBaseResource): - """Representation of a Passwordless Session Response as returned by WorkOS through the Magic Link feature. - Attributes: - OBJECT_FIELDS (list): List of fields a WorkOSPasswordlessSession is comprised of. - """ +class PasswordlessSession(WorkOSModel): + """Representation of a WorkOS Passwordless Session Response.""" - OBJECT_FIELDS = [ - "object", - "id", - "email", - "expires_at", - "link", - ] - - @classmethod - def construct_from_response(cls, response): - create_session_response = super( - WorkOSPasswordlessSession, cls - ).construct_from_response(response) - - return create_session_response - - def to_dict(self): - passwordless_session_response = super(WorkOSPasswordlessSession, self).to_dict() - - return passwordless_session_response + object: Literal["passwordless_session"] + id: str + email: str + expires_at: str + link: str From f3c1a2e24a32e0fb365ec60ffc431b7b2c7f9876 Mon Sep 17 00:00:00 2001 From: pantera Date: Tue, 30 Jul 2024 09:54:34 -0700 Subject: [PATCH 15/18] Type the rest of the events (#301) * Add sso events * Remove unused import * Finish off the rest of the authentication events * Email verification created event * Organization events * Invitation event * Magic auth event * Refactor email verification payload * Add missing created_at to authentication result events * Organization domain events * Organization membership events * Password reset event * Make sure event models have the word event in them * Role events * Session created event * User events * Typo list -> List * Fix regression of connection * Remove unneccessary type variable * Remove unused connection status * Domain verification failure reason should be literal or untyped * Missed a bunch of literal or untyped annotations * Change listable resource --- workos/resources/events.py | 279 ++++++++++++++++-- workos/resources/list.py | 4 +- workos/resources/organizations.py | 17 +- workos/resources/sso.py | 16 +- workos/resources/user_management.py | 56 +--- workos/sso.py | 30 +- workos/types/directory_sync/directory_user.py | 4 +- workos/types/events/authentication_payload.py | 61 ++++ .../connection_payload_with_legacy_fields.py | 6 + ...tion_domain_verification_failed_payload.py | 14 + .../types/events/session_created_payload.py | 15 + workos/types/organizations/__init__.py | 0 .../organizations/organization_common.py | 12 + .../organizations/organization_domain.py | 15 + workos/types/roles/__init__.py | 0 workos/types/roles/role.py | 8 + workos/types/sso/__init__.py | 0 workos/types/sso/connection.py | 20 ++ workos/types/user_management/__init__.py | 0 .../email_verification_common.py | 12 + workos/types/user_management/impersonator.py | 8 + .../user_management/invitation_common.py | 19 ++ .../user_management/magic_auth_common.py | 12 + .../user_management/password_reset_common.py | 11 + 24 files changed, 500 insertions(+), 119 deletions(-) create mode 100644 workos/types/events/authentication_payload.py create mode 100644 workos/types/events/connection_payload_with_legacy_fields.py create mode 100644 workos/types/events/organization_domain_verification_failed_payload.py create mode 100644 workos/types/events/session_created_payload.py create mode 100644 workos/types/organizations/__init__.py create mode 100644 workos/types/organizations/organization_common.py create mode 100644 workos/types/organizations/organization_domain.py create mode 100644 workos/types/roles/__init__.py create mode 100644 workos/types/roles/role.py create mode 100644 workos/types/sso/__init__.py create mode 100644 workos/types/sso/connection.py create mode 100644 workos/types/user_management/__init__.py create mode 100644 workos/types/user_management/email_verification_common.py create mode 100644 workos/types/user_management/impersonator.py create mode 100644 workos/types/user_management/invitation_common.py create mode 100644 workos/types/user_management/magic_auth_common.py create mode 100644 workos/types/user_management/password_reset_common.py diff --git a/workos/resources/events.py b/workos/resources/events.py index 3d97e679..81bfe3e1 100644 --- a/workos/resources/events.py +++ b/workos/resources/events.py @@ -2,8 +2,22 @@ from typing_extensions import Annotated from pydantic import Field from workos.resources.directory_sync import DirectoryGroup +from workos.resources.user_management import OrganizationMembership, User from workos.resources.workos_model import WorkOSModel from workos.types.directory_sync.directory_user import DirectoryUser +from workos.types.events.authentication_payload import ( + AuthenticationEmailVerificationSucceededPayload, + AuthenticationMagicAuthFailedPayload, + AuthenticationMagicAuthSucceededPayload, + AuthenticationMfaSucceededPayload, + AuthenticationOauthSucceededPayload, + AuthenticationPasswordFailedPayload, + AuthenticationPasswordSucceededPayload, + AuthenticationSsoSucceededPayload, +) +from workos.types.events.connection_payload_with_legacy_fields import ( + ConnectionPayloadWithLegacyFields, +) from workos.types.events.directory_group_membership_payload import ( DirectoryGroupMembershipPayload, ) @@ -17,9 +31,34 @@ from workos.types.events.directory_user_with_previous_attributes import ( DirectoryUserWithPreviousAttributes, ) +from workos.types.events.organization_domain_verification_failed_payload import ( + OrganizationDomainVerificationFailedPayload, +) +from workos.types.events.session_created_payload import SessionCreatedPayload +from workos.types.organizations.organization_common import OrganizationCommon +from workos.types.organizations.organization_domain import OrganizationDomain +from workos.types.roles.role import Role +from workos.types.sso.connection import Connection +from workos.types.user_management.email_verification_common import ( + EmailVerificationCommon, +) +from workos.types.user_management.invitation_common import InvitationCommon +from workos.types.user_management.magic_auth_common import MagicAuthCommon +from workos.types.user_management.password_reset_common import PasswordResetCommon from workos.typing.literals import LiteralOrUntyped EventType = Literal[ + "authentication.email_verification_succeeded", + "authentication.magic_auth_failed", + "authentication.magic_auth_succeeded", + "authentication.mfa_succeeded", + "authentication.oauth_succeeded", + "authentication.password_failed", + "authentication.password_succeeded", + "authentication.sso_succeeded", + "connection.activated", + "connection.deactivated", + "connection.deleted", "dsync.activated", "dsync.deleted", "dsync.group.created", @@ -30,10 +69,39 @@ "dsync.user.updated", "dsync.group.user_added", "dsync.group.user_removed", + "email_verification.created", + "invitation.created", + "magic_auth.created", + "organization.created", + "organization.deleted", + "organization.updated", + "organization_domain.verification_failed", + "organization_domain.verified", + "organization_membership.created", + "organization_membership.deleted", + "organization_membership.updated", + "password_reset.created", + "role.created", + "role.deleted", + "role.updated", + "session.created", + "user.created", + "user.deleted", + "user.updated", ] EventTypeDiscriminator = TypeVar("EventTypeDiscriminator", bound=EventType) EventPayload = TypeVar( "EventPayload", + AuthenticationEmailVerificationSucceededPayload, + AuthenticationMagicAuthFailedPayload, + AuthenticationMagicAuthSucceededPayload, + AuthenticationMfaSucceededPayload, + AuthenticationOauthSucceededPayload, + AuthenticationPasswordFailedPayload, + AuthenticationPasswordSucceededPayload, + AuthenticationSsoSucceededPayload, + Connection, + ConnectionPayloadWithLegacyFields, DirectoryPayload, DirectoryPayloadWithLegacyFields, DirectoryGroup, @@ -41,10 +109,21 @@ DirectoryUser, DirectoryUserWithPreviousAttributes, DirectoryGroupMembershipPayload, + EmailVerificationCommon, + InvitationCommon, + MagicAuthCommon, + OrganizationCommon, + OrganizationDomain, + OrganizationDomainVerificationFailedPayload, + OrganizationMembership, + PasswordResetCommon, + Role, + SessionCreatedPayload, + User, ) -class EventModel(WorkOSModel, Generic[EventTypeDiscriminator, EventPayload]): +class EventModel(WorkOSModel, Generic[EventPayload]): # TODO: fix these docs """Representation of an Event returned from the Events API or via Webhook. Attributes: @@ -53,71 +132,197 @@ class EventModel(WorkOSModel, Generic[EventTypeDiscriminator, EventPayload]): id: str object: Literal["event"] - event: LiteralOrUntyped[EventTypeDiscriminator] data: EventPayload created_at: str -class DirectoryActivatedEvent( - EventModel[Literal["dsync.activated"], DirectoryPayloadWithLegacyFields] +class AuthenticationEmailVerificationSucceededEvent( + EventModel[AuthenticationEmailVerificationSucceededPayload,] +): + event: Literal["authentication.email_verification_succeeded"] + + +class AuthenticationMagicAuthFailedEvent( + EventModel[AuthenticationMagicAuthFailedPayload,] +): + event: Literal["authentication.magic_auth_failed"] + + +class AuthenticationMagicAuthSucceededEvent( + EventModel[AuthenticationMagicAuthSucceededPayload,] ): + event: Literal["authentication.magic_auth_succeeded"] + + +class AuthenticationMfaSucceededEvent(EventModel[AuthenticationMfaSucceededPayload]): + event: Literal["authentication.mfa_succeeded"] + + +class AuthenticationOauthSucceededEvent( + EventModel[AuthenticationOauthSucceededPayload] +): + event: Literal["authentication.oauth_succeeded"] + + +class AuthenticationPasswordFailedEvent( + EventModel[AuthenticationPasswordFailedPayload] +): + event: Literal["authentication.password_failed"] + + +class AuthenticationPasswordSucceededEvent( + EventModel[AuthenticationPasswordSucceededPayload,] +): + event: Literal["authentication.password_succeeded"] + + +class AuthenticationSsoSucceededEvent(EventModel[AuthenticationSsoSucceededPayload]): + event: Literal["authentication.sso_succeeded"] + + +class ConnectionActivatedEvent(EventModel[ConnectionPayloadWithLegacyFields]): + event: Literal["connection.activated"] + + +class ConnectionDeactivatedEvent(EventModel[ConnectionPayloadWithLegacyFields]): + event: Literal["connection.deactivated"] + + +class ConnectionDeletedEvent(EventModel[Connection]): + event: Literal["connection.deleted"] + + +class DirectoryActivatedEvent(EventModel[DirectoryPayloadWithLegacyFields]): event: Literal["dsync.activated"] -class DirectoryDeletedEvent(EventModel[Literal["dsync.deleted"], DirectoryPayload]): +class DirectoryDeletedEvent(EventModel[DirectoryPayload]): event: Literal["dsync.deleted"] -class DirectoryGroupCreatedEvent( - EventModel[Literal["dsync.group.created"], DirectoryGroup] -): +class DirectoryGroupCreatedEvent(EventModel[DirectoryGroup]): event: Literal["dsync.group.created"] -class DirectoryGroupDeletedEvent( - EventModel[Literal["dsync.group.deleted"], DirectoryGroup] -): +class DirectoryGroupDeletedEvent(EventModel[DirectoryGroup]): event: Literal["dsync.group.deleted"] -class DirectoryGroupUpdatedEvent( - EventModel[Literal["dsync.group.updated"], DirectoryGroupWithPreviousAttributes] -): +class DirectoryGroupUpdatedEvent(EventModel[DirectoryGroupWithPreviousAttributes]): event: Literal["dsync.group.updated"] -class DirectoryUserCreatedEvent( - EventModel[Literal["dsync.user.created"], DirectoryUser] -): +class DirectoryUserCreatedEvent(EventModel[DirectoryUser]): event: Literal["dsync.user.created"] -class DirectoryUserDeletedEvent( - EventModel[Literal["dsync.user.deleted"], DirectoryUser] -): +class DirectoryUserDeletedEvent(EventModel[DirectoryUser]): event: Literal["dsync.user.deleted"] -class DirectoryUserUpdatedEvent( - EventModel[Literal["dsync.user.updated"], DirectoryUserWithPreviousAttributes] -): +class DirectoryUserUpdatedEvent(EventModel[DirectoryUserWithPreviousAttributes]): event: Literal["dsync.user.updated"] -class DirectoryUserAddedToGroupEvent( - EventModel[Literal["dsync.group.user_added"], DirectoryGroupMembershipPayload] -): +class DirectoryUserAddedToGroupEvent(EventModel[DirectoryGroupMembershipPayload]): event: Literal["dsync.group.user_added"] -class DirectoryUserRemovedFromGroupEvent( - EventModel[Literal["dsync.group.user_removed"], DirectoryGroupMembershipPayload] -): +class DirectoryUserRemovedFromGroupEvent(EventModel[DirectoryGroupMembershipPayload]): event: Literal["dsync.group.user_removed"] +class EmailVerificationCreatedEvent(EventModel[EmailVerificationCommon]): + event: Literal["email_verification.created"] + + +class InvitationCreatedEvent(EventModel[InvitationCommon]): + event: Literal["invitation.created"] + + +class MagicAuthCreatedEvent(EventModel[MagicAuthCommon]): + event: Literal["magic_auth.created"] + + +class OrganizationCreatedEvent(EventModel[OrganizationCommon]): + event: Literal["organization.created"] + + +class OrganizationDeletedEvent(EventModel[OrganizationCommon]): + event: Literal["organization.deleted"] + + +class OrganizationUpdatedEvent(EventModel[OrganizationCommon]): + event: Literal["organization.updated"] + + +class OrganizationDomainVerificationFailedEvent( + EventModel[OrganizationDomainVerificationFailedPayload,] +): + event: Literal["organization_domain.verification_failed"] + + +class OrganizationDomainVerifiedEvent(EventModel[OrganizationDomain]): + event: Literal["organization_domain.verified"] + + +class OrganizationMembershipCreatedEvent(EventModel[OrganizationMembership]): + event: Literal["organization_membership.created"] + + +class OrganizationMembershipDeletedEvent(EventModel[OrganizationMembership]): + event: Literal["organization_membership.deleted"] + + +class OrganizationMembershipUpdatedEvent(EventModel[OrganizationMembership]): + event: Literal["organization_membership.updated"] + + +class PasswordResetCreatedEvent(EventModel[PasswordResetCommon]): + event: Literal["password_reset.created"] + + +class RoleCreatedEvent(EventModel[Role]): + event: Literal["role.created"] + + +class RoleDeletedEvent(EventModel[Role]): + event: Literal["role.deleted"] + + +class RoleUpdatedEvent(EventModel[Role]): + event: Literal["role.updated"] + + +class SessionCreatedEvent(EventModel[SessionCreatedPayload]): + event: Literal["session.created"] + + +class UserCreatedEvent(EventModel[User]): + event: Literal["user.created"] + + +class UserDeletedEvent(EventModel[User]): + event: Literal["user.deleted"] + + +class UserUpdatedEvent(EventModel[User]): + event: Literal["user.updated"] + + Event = Annotated[ Union[ + AuthenticationEmailVerificationSucceededEvent, + AuthenticationMagicAuthFailedEvent, + AuthenticationMagicAuthSucceededEvent, + AuthenticationMfaSucceededEvent, + AuthenticationOauthSucceededEvent, + AuthenticationPasswordFailedEvent, + AuthenticationPasswordSucceededEvent, + AuthenticationSsoSucceededEvent, + ConnectionActivatedEvent, + ConnectionDeactivatedEvent, + ConnectionDeletedEvent, DirectoryActivatedEvent, DirectoryDeletedEvent, DirectoryGroupCreatedEvent, @@ -128,6 +333,22 @@ class DirectoryUserRemovedFromGroupEvent( DirectoryUserUpdatedEvent, DirectoryUserAddedToGroupEvent, DirectoryUserRemovedFromGroupEvent, + EmailVerificationCreatedEvent, + InvitationCreatedEvent, + MagicAuthCreatedEvent, + OrganizationCreatedEvent, + OrganizationDeletedEvent, + OrganizationUpdatedEvent, + OrganizationDomainVerificationFailedEvent, + OrganizationDomainVerifiedEvent, + PasswordResetCreatedEvent, + RoleCreatedEvent, + RoleDeletedEvent, + RoleUpdatedEvent, + SessionCreatedEvent, + UserCreatedEvent, + UserDeletedEvent, + UserUpdatedEvent, ], Field(..., discriminator="event"), ] diff --git a/workos/resources/list.py b/workos/resources/list.py index 1bd3ddff..1a5264d3 100644 --- a/workos/resources/list.py +++ b/workos/resources/list.py @@ -24,7 +24,7 @@ from workos.resources.mfa import AuthenticationFactor from workos.resources.organizations import Organization from pydantic import BaseModel, Field -from workos.resources.sso import Connection +from workos.resources.sso import Connection, ConnectionWithDomains from workos.resources.user_management import Invitation, OrganizationMembership, User from workos.resources.workos_model import WorkOSModel @@ -124,7 +124,7 @@ def auto_paging_iter(self): # add all possible generics of List Resource "ListableResource", AuthenticationFactor, - Connection, + ConnectionWithDomains, Directory, DirectoryGroup, DirectoryUserWithGroups, diff --git a/workos/resources/organizations.py b/workos/resources/organizations.py index 8bda09a3..e4d1a63c 100644 --- a/workos/resources/organizations.py +++ b/workos/resources/organizations.py @@ -1,24 +1,11 @@ from typing import Literal, Optional from typing_extensions import TypedDict from workos.resources.workos_model import WorkOSModel +from workos.types.organizations.organization_common import OrganizationCommon -class OrganizationDomain(WorkOSModel): - id: str - organization_id: str - object: Literal["organization_domain"] - verification_strategy: Literal["manual", "dns"] - state: Literal["failed", "pending", "legacy_verified", "verified"] - domain: str - - -class Organization(WorkOSModel): - id: str - object: Literal["organization"] - name: str +class Organization(OrganizationCommon): allow_profiles_outside_organization: bool - created_at: str - updated_at: str domains: list lookup_key: Optional[str] = None diff --git a/workos/resources/sso.py b/workos/resources/sso.py index 1d9216d3..4f460dd1 100644 --- a/workos/resources/sso.py +++ b/workos/resources/sso.py @@ -1,6 +1,7 @@ from typing import List, Literal, Union from workos.resources.workos_model import WorkOSModel +from workos.types.sso.connection import Connection from workos.typing.literals import LiteralOrUntyped from workos.utils.connection_types import ConnectionType @@ -28,28 +29,15 @@ class ProfileAndToken(WorkOSModel): profile: Profile -ConnectionState = Literal[ - "active", "deleting", "inactive", "requires_type", "validating" -] - - class ConnectionDomain(WorkOSModel): object: Literal["connection_domain"] id: str domain: str -class Connection(WorkOSModel): +class ConnectionWithDomains(Connection): """Representation of a Connection Response as returned by WorkOS through the SSO feature.""" - object: Literal["connection"] - id: str - organization_id: str - connection_type: LiteralOrUntyped[ConnectionType] - name: str - state: LiteralOrUntyped[ConnectionState] - created_at: str - updated_at: str domains: List[ConnectionDomain] diff --git a/workos/resources/user_management.py b/workos/resources/user_management.py index 5f61253c..f396af04 100644 --- a/workos/resources/user_management.py +++ b/workos/resources/user_management.py @@ -2,6 +2,13 @@ from typing_extensions import TypedDict from workos.resources.workos_model import WorkOSModel +from workos.types.user_management.email_verification_common import ( + EmailVerificationCommon, +) +from workos.types.user_management.impersonator import Impersonator +from workos.types.user_management.invitation_common import InvitationCommon +from workos.types.user_management.magic_auth_common import MagicAuthCommon +from workos.types.user_management.password_reset_common import PasswordResetCommon PasswordHashType = Literal["bcrypt", "firebase-scrypt", "ssha"] @@ -32,13 +39,6 @@ class User(WorkOSModel): updated_at: str -class Impersonator(WorkOSModel): - """Representation of a WorkOS Dashboard member impersonating a user""" - - email: str - reason: str - - class AuthenticationResponse(WorkOSModel): """Representation of a WorkOS User and Organization ID response.""" @@ -57,64 +57,30 @@ class RefreshTokenAuthenticationResponse(WorkOSModel): refresh_token: str -class EmailVerification(WorkOSModel): +class EmailVerification(EmailVerificationCommon): """Representation of a WorkOS EmailVerification object.""" - object: Literal["email_verification"] - id: str - user_id: str - email: str - expires_at: str code: str - created_at: str - updated_at: str - -InvitationState = Literal["accepted", "expired", "pending", "revoked"] - -class Invitation(WorkOSModel): +class Invitation(InvitationCommon): """Representation of a WorkOS Invitation as returned.""" - object: Literal["invitation"] - id: str - email: str - state: InvitationState - accepted_at: Optional[str] = None - revoked_at: Optional[str] = None - expires_at: str token: str accept_invitation_url: str - organization_id: Optional[str] = None - inviter_user_id: Optional[str] = None - created_at: str - updated_at: str -class MagicAuth(WorkOSModel): +class MagicAuth(MagicAuthCommon): """Representation of a WorkOS MagicAuth object.""" - object: Literal["magic_auth"] - id: str - user_id: str - email: str - expires_at: str code: str - created_at: str - updated_at: str -class PasswordReset(WorkOSModel): +class PasswordReset(PasswordResetCommon): """Representation of a WorkOS PasswordReset object.""" - object: Literal["password_reset"] - id: str - user_id: str - email: str password_reset_token: str password_reset_url: str - expires_at: str - created_at: str class OrganizationMembershipRole(TypedDict): diff --git a/workos/sso.py b/workos/sso.py index 915baf73..dd83634d 100644 --- a/workos/sso.py +++ b/workos/sso.py @@ -5,7 +5,7 @@ from workos.utils.http_client import AsyncHTTPClient, SyncHTTPClient from workos.utils.pagination_order import PaginationOrder from workos.resources.sso import ( - Connection, + ConnectionWithDomains, Profile, ProfileAndToken, SsoProviderType, @@ -103,7 +103,7 @@ def get_profile(self, accessToken: str) -> SyncOrAsync[Profile]: ... def get_profile_and_token(self, code: str) -> SyncOrAsync[ProfileAndToken]: ... - def get_connection(self, connection: str) -> SyncOrAsync[Connection]: ... + def get_connection(self, connection: str) -> SyncOrAsync[ConnectionWithDomains]: ... def list_connections( self, @@ -169,7 +169,7 @@ def get_profile_and_token(self, code: str) -> ProfileAndToken: return ProfileAndToken.model_validate(response) - def get_connection(self, connection_id: str) -> Connection: + def get_connection(self, connection_id: str) -> ConnectionWithDomains: """Gets details for a single Connection Args: @@ -184,7 +184,7 @@ def get_connection(self, connection_id: str) -> Connection: token=workos.api_key, ) - return Connection.model_validate(response) + return ConnectionWithDomains.model_validate(response) def list_connections( self, @@ -195,7 +195,9 @@ def list_connections( before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", - ) -> WorkOsListResource[Connection, ConnectionsListFilters, ListMetadata]: + ) -> WorkOsListResource[ + ConnectionWithDomains, ConnectionsListFilters, ListMetadata + ]: """Gets details for existing Connections. Args: @@ -227,10 +229,12 @@ def list_connections( token=workos.api_key, ) - return WorkOsListResource[Connection, ConnectionsListFilters, ListMetadata]( + return WorkOsListResource[ + ConnectionWithDomains, ConnectionsListFilters, ListMetadata + ]( list_method=self.list_connections, list_args=params, - **ListPage[Connection](**response).model_dump(), + **ListPage[ConnectionWithDomains](**response).model_dump(), ) def delete_connection(self, connection_id: str) -> None: @@ -296,7 +300,7 @@ async def get_profile_and_token(self, code: str) -> ProfileAndToken: return ProfileAndToken.model_validate(response) - async def get_connection(self, connection_id: str) -> Connection: + async def get_connection(self, connection_id: str) -> ConnectionWithDomains: """Gets details for a single Connection Args: @@ -311,7 +315,7 @@ async def get_connection(self, connection_id: str) -> Connection: token=workos.api_key, ) - return Connection.model_validate(response) + return ConnectionWithDomains.model_validate(response) async def list_connections( self, @@ -322,7 +326,9 @@ async def list_connections( before: Optional[str] = None, after: Optional[str] = None, order: PaginationOrder = "desc", - ) -> AsyncWorkOsListResource[Connection, ConnectionsListFilters, ListMetadata]: + ) -> AsyncWorkOsListResource[ + ConnectionWithDomains, ConnectionsListFilters, ListMetadata + ]: """Gets details for existing Connections. Args: @@ -355,11 +361,11 @@ async def list_connections( ) return AsyncWorkOsListResource[ - Connection, ConnectionsListFilters, ListMetadata + ConnectionWithDomains, ConnectionsListFilters, ListMetadata ]( list_method=self.list_connections, list_args=params, - **ListPage[Connection](**response).model_dump(), + **ListPage[ConnectionWithDomains](**response).model_dump(), ) async def delete_connection(self, connection_id: str) -> None: diff --git a/workos/types/directory_sync/directory_user.py b/workos/types/directory_sync/directory_user.py index 086ee11c..18704d66 100644 --- a/workos/types/directory_sync/directory_user.py +++ b/workos/types/directory_sync/directory_user.py @@ -12,7 +12,7 @@ class DirectoryUserEmail(WorkOSModel): primary: Optional[bool] = None -class Role(WorkOSModel): +class InlineRole(WorkOSModel): slug: str @@ -32,7 +32,7 @@ class DirectoryUser(WorkOSModel): raw_attributes: dict created_at: str updated_at: str - role: Optional[Role] = None + role: Optional[InlineRole] = None def primary_email(self): return next((email for email in self.emails if email.primary), None) diff --git a/workos/types/events/authentication_payload.py b/workos/types/events/authentication_payload.py new file mode 100644 index 00000000..b7045e90 --- /dev/null +++ b/workos/types/events/authentication_payload.py @@ -0,0 +1,61 @@ +from typing import Literal, Union +from workos.resources.workos_model import WorkOSModel + + +class AuthenticationResultCommon(WorkOSModel): + ip_address: Union[str, None] + user_agent: Union[str, None] + email: str + created_at: str + + +class AuthenticationResultSucceeded(AuthenticationResultCommon): + status: Literal["succeeded"] + + +class ErrorWithCode(WorkOSModel): + code: str + message: str + + +class AuthenticationResultFailed(AuthenticationResultCommon): + status: Literal["failed"] + error: ErrorWithCode + + +class AuthenticationEmailVerificationSucceededPayload(AuthenticationResultSucceeded): + type: Literal["email_verification"] + user_id: str + + +class AuthenticationMagicAuthFailedPayload(AuthenticationResultFailed): + type: Literal["magic_auth"] + + +class AuthenticationMagicAuthSucceededPayload(AuthenticationResultSucceeded): + type: Literal["magic_auth"] + user_id: str + + +class AuthenticationMfaSucceededPayload(AuthenticationResultSucceeded): + type: Literal["mfa"] + user_id: str + + +class AuthenticationOauthSucceededPayload(AuthenticationResultSucceeded): + type: Literal["oath"] + user_id: str + + +class AuthenticationPasswordFailedPayload(AuthenticationResultFailed): + type: Literal["password"] + + +class AuthenticationPasswordSucceededPayload(AuthenticationResultSucceeded): + type: Literal["password"] + user_id: str + + +class AuthenticationSsoSucceededPayload(AuthenticationResultSucceeded): + type: Literal["sso"] + user_id: Union[str, None] diff --git a/workos/types/events/connection_payload_with_legacy_fields.py b/workos/types/events/connection_payload_with_legacy_fields.py new file mode 100644 index 00000000..a3d48e01 --- /dev/null +++ b/workos/types/events/connection_payload_with_legacy_fields.py @@ -0,0 +1,6 @@ +from typing import Literal +from workos.resources.sso import ConnectionWithDomains + + +class ConnectionPayloadWithLegacyFields(ConnectionWithDomains): + external_key: str diff --git a/workos/types/events/organization_domain_verification_failed_payload.py b/workos/types/events/organization_domain_verification_failed_payload.py new file mode 100644 index 00000000..9e99fd50 --- /dev/null +++ b/workos/types/events/organization_domain_verification_failed_payload.py @@ -0,0 +1,14 @@ +from typing import Literal +from workos.resources.workos_model import WorkOSModel +from workos.types.organizations.organization_domain import OrganizationDomain +from workos.typing.literals import LiteralOrUntyped + + +class OrganizationDomainVerificationFailedPayload(WorkOSModel): + reason: LiteralOrUntyped[ + Literal[ + "domain_verification_period_expired", + "domain_verified_by_other_organization", + ] + ] + organization_domain: OrganizationDomain diff --git a/workos/types/events/session_created_payload.py b/workos/types/events/session_created_payload.py new file mode 100644 index 00000000..1a9633ae --- /dev/null +++ b/workos/types/events/session_created_payload.py @@ -0,0 +1,15 @@ +from typing import Literal, Optional +from workos.resources.workos_model import WorkOSModel +from workos.types.user_management.impersonator import Impersonator + + +class SessionCreatedPayload(WorkOSModel): + object: Literal["session"] + id: str + impersonator: Optional[Impersonator] = None + ip_address: Optional[str] = None + organization_id: Optional[str] = None + user_agent: Optional[str] = None + user_id: str + created_at: str + updated_at: str diff --git a/workos/types/organizations/__init__.py b/workos/types/organizations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/workos/types/organizations/organization_common.py b/workos/types/organizations/organization_common.py new file mode 100644 index 00000000..898ab912 --- /dev/null +++ b/workos/types/organizations/organization_common.py @@ -0,0 +1,12 @@ +from typing import Literal, List +from workos.resources.workos_model import WorkOSModel +from workos.types.organizations.organization_domain import OrganizationDomain + + +class OrganizationCommon(WorkOSModel): + id: str + object: Literal["organization"] + name: str + domains: List[OrganizationDomain] + created_at: str + updated_at: str diff --git a/workos/types/organizations/organization_domain.py b/workos/types/organizations/organization_domain.py new file mode 100644 index 00000000..9023295b --- /dev/null +++ b/workos/types/organizations/organization_domain.py @@ -0,0 +1,15 @@ +from typing import Literal, Optional +from workos.resources.workos_model import WorkOSModel +from workos.typing.literals import LiteralOrUntyped + + +class OrganizationDomain(WorkOSModel): + id: str + organization_id: str + object: Literal["organization_domain"] + domain: str + state: Optional[ + LiteralOrUntyped[Literal["failed", "pending", "legacy_verified", "verified"]] + ] = None + verification_strategy: Optional[LiteralOrUntyped[Literal["manual", "dns"]]] = None + verification_token: Optional[str] = None diff --git a/workos/types/roles/__init__.py b/workos/types/roles/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/workos/types/roles/role.py b/workos/types/roles/role.py new file mode 100644 index 00000000..132af9b2 --- /dev/null +++ b/workos/types/roles/role.py @@ -0,0 +1,8 @@ +from typing import List, Literal, Optional +from workos.resources.workos_model import WorkOSModel + + +class Role(WorkOSModel): + object: Literal["role"] + slug: str + permissions: Optional[List[str]] = None diff --git a/workos/types/sso/__init__.py b/workos/types/sso/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/workos/types/sso/connection.py b/workos/types/sso/connection.py new file mode 100644 index 00000000..38baba68 --- /dev/null +++ b/workos/types/sso/connection.py @@ -0,0 +1,20 @@ +from typing import Literal + +from workos.resources.workos_model import WorkOSModel +from workos.typing.literals import LiteralOrUntyped +from workos.utils.connection_types import ConnectionType + +ConnectionState = Literal[ + "active", "deleting", "inactive", "requires_type", "validating" +] + + +class Connection(WorkOSModel): + object: Literal["connection"] + id: str + organization_id: str + connection_type: LiteralOrUntyped[ConnectionType] + name: str + state: LiteralOrUntyped[ConnectionState] + created_at: str + updated_at: str diff --git a/workos/types/user_management/__init__.py b/workos/types/user_management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/workos/types/user_management/email_verification_common.py b/workos/types/user_management/email_verification_common.py new file mode 100644 index 00000000..fd0bb279 --- /dev/null +++ b/workos/types/user_management/email_verification_common.py @@ -0,0 +1,12 @@ +from typing import Literal +from workos.resources.workos_model import WorkOSModel + + +class EmailVerificationCommon(WorkOSModel): + object: Literal["email_verification"] + id: str + user_id: str + email: str + expires_at: str + created_at: str + updated_at: str diff --git a/workos/types/user_management/impersonator.py b/workos/types/user_management/impersonator.py new file mode 100644 index 00000000..7369d0ae --- /dev/null +++ b/workos/types/user_management/impersonator.py @@ -0,0 +1,8 @@ +from workos.resources.workos_model import WorkOSModel + + +class Impersonator(WorkOSModel): + """Representation of a WorkOS Dashboard member impersonating a user""" + + email: str + reason: str diff --git a/workos/types/user_management/invitation_common.py b/workos/types/user_management/invitation_common.py new file mode 100644 index 00000000..0c8ed401 --- /dev/null +++ b/workos/types/user_management/invitation_common.py @@ -0,0 +1,19 @@ +from typing import Literal, Optional +from workos.resources.workos_model import WorkOSModel +from workos.typing.literals import LiteralOrUntyped + +InvitationState = Literal["accepted", "expired", "pending", "revoked"] + + +class InvitationCommon(WorkOSModel): + object: Literal["invitation"] + id: str + email: str + state: LiteralOrUntyped[InvitationState] + accepted_at: Optional[str] = None + revoked_at: Optional[str] = None + expires_at: str + organization_id: Optional[str] = None + inviter_user_id: Optional[str] = None + created_at: str + updated_at: str diff --git a/workos/types/user_management/magic_auth_common.py b/workos/types/user_management/magic_auth_common.py new file mode 100644 index 00000000..afc2aab2 --- /dev/null +++ b/workos/types/user_management/magic_auth_common.py @@ -0,0 +1,12 @@ +from typing import Literal +from workos.resources.workos_model import WorkOSModel + + +class MagicAuthCommon(WorkOSModel): + object: Literal["magic_auth"] + id: str + user_id: str + email: str + expires_at: str + created_at: str + updated_at: str diff --git a/workos/types/user_management/password_reset_common.py b/workos/types/user_management/password_reset_common.py new file mode 100644 index 00000000..a8e90c34 --- /dev/null +++ b/workos/types/user_management/password_reset_common.py @@ -0,0 +1,11 @@ +from typing import Literal +from workos.resources.workos_model import WorkOSModel + + +class PasswordResetCommon(WorkOSModel): + object: Literal["password_reset"] + id: str + user_id: str + email: str + expires_at: str + created_at: str From 53b58416e655494a569581fe6d4539c78fc06473 Mon Sep 17 00:00:00 2001 From: Matt Dzwonczyk <9063128+mattgd@users.noreply.github.com> Date: Tue, 30 Jul 2024 13:51:35 -0400 Subject: [PATCH 16/18] Clean up requests dependency (#302) --- setup.py | 16 +-- tests/conftest.py | 100 ------------------ tests/test_async_http_client.py | 144 +++++++++++++++++++++++++- tests/test_sso.py | 2 +- tests/test_sync_http_client.py | 153 ++++++++++++++++++++++++++++ tests/test_user_management.py | 2 +- tests/test_webhooks.py | 2 +- tests/utils/test_request_helper.py | 13 +++ tests/utils/test_requests.py | 156 ----------------------------- workos/audit_logs.py | 2 +- workos/directory_sync.py | 2 +- workos/events.py | 2 +- workos/mfa.py | 2 +- workos/organizations.py | 2 +- workos/passwordless.py | 2 +- workos/portal.py | 2 +- workos/sso.py | 2 +- workos/user_management.py | 2 +- workos/utils/_base_http_client.py | 2 +- workos/utils/http_client.py | 2 +- workos/utils/request.py | 133 ------------------------ workos/utils/request_helper.py | 21 ++++ workos/webhooks.py | 2 +- 23 files changed, 349 insertions(+), 417 deletions(-) create mode 100644 tests/utils/test_request_helper.py delete mode 100644 tests/utils/test_requests.py delete mode 100644 workos/utils/request.py create mode 100644 workos/utils/request_helper.py diff --git a/setup.py b/setup.py index d5011429..0705b87a 100644 --- a/setup.py +++ b/setup.py @@ -26,12 +26,7 @@ ), zip_safe=False, license=about["__license__"], - install_requires=[ - "httpx>=0.27.0", - "requests>=2.22.0", - "pydantic==2.8.2", - "types-requests==2.32.0.20240712", - ], + install_requires=["httpx>=0.27.0", "pydantic==2.8.2"], extras_require={ "dev": [ "flake8", @@ -41,7 +36,6 @@ "six==1.16.0", "black==24.4.2", "twine==5.1.1", - "requests==2.30.0", "mypy==1.11.0", "httpx>=0.27.0", ], @@ -54,10 +48,10 @@ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.4", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ], ) diff --git a/tests/conftest.py b/tests/conftest.py index 3be3b68a..13495972 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,6 @@ import httpx import pytest -import requests from tests.utils.list_resource import list_data_to_dicts, list_response_of import workos @@ -11,26 +10,6 @@ from workos.utils.http_client import AsyncHTTPClient, SyncHTTPClient -class MockResponse(object): - def __init__(self, response_dict, status_code, headers=None): - self.response_dict = response_dict - self.status_code = status_code - self.headers = {} if headers is None else headers - - if "content-type" not in self.headers: - self.headers["content-type"] = "application/json" - - def json(self): - return self.response_dict - - -class MockRawResponse(object): - def __init__(self, content, status_code, headers=None): - self.content = content - self.status_code = status_code - self.headers = {} if headers is None else headers - - @pytest.fixture def set_api_key(monkeypatch): monkeypatch.setattr(workos, "api_key", "sk_test") @@ -46,85 +25,6 @@ def set_api_key_and_client_id(set_api_key, set_client_id): pass -@pytest.fixture -def mock_request_method(monkeypatch): - def inner(method, response_dict, status_code, headers=None): - def mock(*args, **kwargs): - return MockResponse(response_dict, status_code, headers=headers) - - monkeypatch.setattr(requests, "request", mock) - - return inner - - -@pytest.fixture -def mock_raw_request_method(monkeypatch): - def inner(method, content, status_code, headers=None): - def mock(*args, **kwargs): - return MockRawResponse(content, status_code, headers=headers) - - monkeypatch.setattr(requests, "request", mock) - - return inner - - -@pytest.fixture -def capture_and_mock_request(monkeypatch): - def inner(method, response_dict, status_code, headers=None): - request_args = [] - request_kwargs = {} - - def capture_and_mock(*args, **kwargs): - request_args.extend(args) - request_kwargs.update(kwargs) - - return MockResponse(response_dict, status_code, headers=headers) - - monkeypatch.setattr(requests, "request", capture_and_mock) - - return (request_args, request_kwargs) - - return inner - - -@pytest.fixture -def mock_pagination_request(monkeypatch): - # Mocking pagination correctly requires us to index into a list of data - # and correctly set the before and after metadata in the response. - def inner(method, data_list, status_code, headers=None): - # For convenient index lookup, store the list of object IDs. - data_ids = list(map(lambda x: x["id"], data_list)) - - def mock(*args, **kwargs): - params = kwargs.get("params") or {} - request_after = params.get("after", None) - limit = params.get("limit", 10) - - if request_after is None: - # First page - start = 0 - else: - # A subsequent page, return the first item _after_ the index we locate - start = data_ids.index(request_after) + 1 - data = data_list[start : start + limit] - if len(data) < limit or len(data) == 0: - # No more data, set after to None - after = None - else: - # Set after to the last item in this page of results - after = data[-1]["id"] - - return MockResponse( - list_response_of(data=data, before=request_after, after=after), - status_code, - headers=headers, - ) - - monkeypatch.setattr(requests, "request", mock) - - return inner - - @pytest.fixture def mock_http_client_with_response(monkeypatch): def inner( diff --git a/tests/test_async_http_client.py b/tests/test_async_http_client.py index 187e6ee1..41cbf329 100644 --- a/tests/test_async_http_client.py +++ b/tests/test_async_http_client.py @@ -4,9 +4,12 @@ import pytest from unittest.mock import AsyncMock +from tests.test_sync_http_client import STATUS_CODE_TO_EXCEPTION_MAPPING +from workos.exceptions import BadRequestException, BaseRequestException, ServerException from workos.utils.http_client import AsyncHTTPClient +@pytest.mark.asyncio class TestAsyncHTTPClient(object): @pytest.fixture(autouse=True) def setup(self): @@ -33,7 +36,6 @@ def handler(request: httpx.Request) -> httpx.Response: ("DELETE", 202, None), ], ) - @pytest.mark.asyncio async def test_request_without_body( self, method: str, status_code: int, expected_response: dict ): @@ -75,7 +77,6 @@ async def test_request_without_body( ("PATCH", 200, {"message": "Success!"}), ], ) - @pytest.mark.asyncio async def test_request_with_body( self, method: str, status_code: int, expected_response: dict ): @@ -105,3 +106,142 @@ async def test_request_with_body( ) assert response == expected_response + + @pytest.mark.parametrize( + "status_code,expected_exception", + STATUS_CODE_TO_EXCEPTION_MAPPING, + ) + async def test_request_raises_expected_exception_for_status_code( + self, status_code: int, expected_exception: BaseRequestException + ): + self.http_client._client.request = AsyncMock( + return_value=httpx.Response(status_code=status_code), + ) + + with pytest.raises(expected_exception): # type: ignore + await self.http_client.request("bad_place") + + @pytest.mark.parametrize( + "status_code,expected_exception", + STATUS_CODE_TO_EXCEPTION_MAPPING, + ) + async def test_request_exceptions_include_expected_request_data( + self, status_code: int, expected_exception: BaseRequestException + ): + request_id = "request-123" + response_message = "stuff happened" + + self.http_client._client.request = AsyncMock( + return_value=httpx.Response( + status_code=status_code, + json={"message": response_message}, + headers={"X-Request-ID": request_id}, + ), + ) + + try: + await self.http_client.request("bad_place") + except expected_exception as ex: # type: ignore + assert ex.message == response_message + assert ex.request_id == request_id + except Exception as ex: + # This'll fail for sure here but... just using the nice error that'd come up + assert ex.__class__ == expected_exception + + async def test_bad_request_exceptions_include_expected_request_data(self): + request_id = "request-123" + error = "example_error" + error_description = "Example error description" + + self.http_client._client.request = AsyncMock( + return_value=httpx.Response( + status_code=400, + json={"error": error, "error_description": error_description}, + headers={"X-Request-ID": request_id}, + ), + ) + + try: + await self.http_client.request("bad_place") + except BadRequestException as ex: + assert ( + str(ex) + == "(message=No message, request_id=request-123, error=example_error, error_description=Example error description)" + ) + except Exception as ex: + assert ex.__class__ == BadRequestException + + async def test_bad_request_exceptions_exclude_expected_request_data(self): + request_id = "request-123" + + self.http_client._client.request = AsyncMock( + return_value=httpx.Response( + status_code=400, + json={"foo": "bar"}, + headers={"X-Request-ID": request_id}, + ), + ) + + try: + await self.http_client.request("bad_place") + except BadRequestException as ex: + assert str(ex) == "(message=No message, request_id=request-123)" + except Exception as ex: + assert ex.__class__ == BadRequestException + + async def test_request_bad_body_raises_expected_exception_with_request_data(self): + request_id = "request-123" + + self.http_client._client.request = AsyncMock( + return_value=httpx.Response( + status_code=200, + content="this_isnt_json", + headers={"X-Request-ID": request_id}, + ), + ) + + try: + await self.http_client.request("bad_place") + except ServerException as ex: + assert ex.message == None + assert ex.request_id == request_id + except Exception as ex: + # This'll fail for sure here but... just using the nice error that'd come up + assert ex.__class__ == ServerException + + async def test_request_includes_base_headers( + self, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request(self.http_client, {}, 200) + + await self.http_client.request("ok_place") + + default_headers = set( + (header[0].lower(), header[1]) + for header in self.http_client.default_headers.items() + ) + headers = set(request_kwargs["headers"].items()) + + assert default_headers.issubset(headers) + + async def test_request_parses_json_when_content_type_present(self): + self.http_client._client.request = AsyncMock( + return_value=httpx.Response( + status_code=200, + json={"foo": "bar"}, + headers={"content-type": "application/json"}, + ), + ) + + assert await self.http_client.request("ok_place") == {"foo": "bar"} + + async def test_request_parses_json_when_encoding_in_content_type(self): + self.http_client._client.request = AsyncMock( + return_value=httpx.Response( + status_code=200, + json={"foo": "bar"}, + headers={"content-type": "application/json; charset=utf8"}, + ), + ) + + assert await self.http_client.request("ok_place") == {"foo": "bar"} diff --git a/tests/test_sso.py b/tests/test_sso.py index 7b2f419e..0a82316c 100644 --- a/tests/test_sso.py +++ b/tests/test_sso.py @@ -8,7 +8,7 @@ from workos.sso import SSO, AsyncSSO from workos.resources.sso import SsoProviderType from workos.utils.http_client import AsyncHTTPClient, SyncHTTPClient -from workos.utils.request import RESPONSE_TYPE_CODE +from workos.utils.request_helper import RESPONSE_TYPE_CODE from tests.utils.fixtures.mock_connection import MockConnection diff --git a/tests/test_sync_http_client.py b/tests/test_sync_http_client.py index faaca2d5..f68d531d 100644 --- a/tests/test_sync_http_client.py +++ b/tests/test_sync_http_client.py @@ -1,12 +1,28 @@ from platform import python_version +from typing import List, Tuple import httpx import pytest from unittest.mock import MagicMock +from workos.exceptions import ( + AuthenticationException, + AuthorizationException, + BadRequestException, + BaseRequestException, + ServerException, +) from workos.utils.http_client import SyncHTTPClient +STATUS_CODE_TO_EXCEPTION_MAPPING = [ + (400, BadRequestException), + (401, AuthenticationException), + (403, AuthorizationException), + (500, ServerException), +] + + class TestSyncHTTPClient(object): @pytest.fixture(autouse=True) def setup(self): @@ -103,3 +119,140 @@ def test_request_with_body( ) assert response == expected_response + + @pytest.mark.parametrize( + "status_code,expected_exception", + STATUS_CODE_TO_EXCEPTION_MAPPING, + ) + def test_request_raises_expected_exception_for_status_code( + self, status_code: int, expected_exception: BaseRequestException + ): + self.http_client._client.request = MagicMock( + return_value=httpx.Response(status_code=status_code), + ) + + with pytest.raises(expected_exception): # type: ignore + self.http_client.request("bad_place") + + @pytest.mark.parametrize( + "status_code,expected_exception", + STATUS_CODE_TO_EXCEPTION_MAPPING, + ) + def test_request_exceptions_include_expected_request_data( + self, status_code: int, expected_exception: BaseRequestException + ): + request_id = "request-123" + response_message = "stuff happened" + + self.http_client._client.request = MagicMock( + return_value=httpx.Response( + status_code=status_code, + json={"message": response_message}, + headers={"X-Request-ID": request_id}, + ), + ) + + try: + self.http_client.request("bad_place") + except expected_exception as ex: # type: ignore + assert ex.message == response_message + assert ex.request_id == request_id + except Exception as ex: + # This'll fail for sure here but... just using the nice error that'd come up + assert ex.__class__ == expected_exception + + def test_bad_request_exceptions_include_expected_request_data(self): + request_id = "request-123" + error = "example_error" + error_description = "Example error description" + + self.http_client._client.request = MagicMock( + return_value=httpx.Response( + status_code=400, + json={"error": error, "error_description": error_description}, + headers={"X-Request-ID": request_id}, + ), + ) + + try: + self.http_client.request("bad_place") + except BadRequestException as ex: + assert ( + str(ex) + == "(message=No message, request_id=request-123, error=example_error, error_description=Example error description)" + ) + except Exception as ex: + assert ex.__class__ == BadRequestException + + def test_bad_request_exceptions_exclude_expected_request_data(self): + request_id = "request-123" + + self.http_client._client.request = MagicMock( + return_value=httpx.Response( + status_code=400, + json={"foo": "bar"}, + headers={"X-Request-ID": request_id}, + ), + ) + + try: + self.http_client.request("bad_place") + except BadRequestException as ex: + assert str(ex) == "(message=No message, request_id=request-123)" + except Exception as ex: + assert ex.__class__ == BadRequestException + + def test_request_bad_body_raises_expected_exception_with_request_data(self): + request_id = "request-123" + + self.http_client._client.request = MagicMock( + return_value=httpx.Response( + status_code=200, + content="this_isnt_json", + headers={"X-Request-ID": request_id}, + ), + ) + + try: + self.http_client.request("bad_place") + except ServerException as ex: + assert ex.message == None + assert ex.request_id == request_id + except Exception as ex: + # This'll fail for sure here but... just using the nice error that'd come up + assert ex.__class__ == ServerException + + def test_request_includes_base_headers(self, capture_and_mock_http_client_request): + request_kwargs = capture_and_mock_http_client_request(self.http_client, {}, 200) + + self.http_client.request("ok_place") + + default_headers = set( + (header[0].lower(), header[1]) + for header in self.http_client.default_headers.items() + ) + headers = set(request_kwargs["headers"].items()) + + assert default_headers.issubset(headers) + + def test_request_parses_json_when_content_type_present(self): + self.http_client._client.request = MagicMock( + return_value=httpx.Response( + status_code=200, + json={"foo": "bar"}, + headers={"content-type": "application/json"}, + ), + ) + + assert self.http_client.request("ok_place") == {"foo": "bar"} + + def test_request_parses_json_when_encoding_in_content_type(self): + self.http_client._client.request = MagicMock( + return_value=httpx.Response( + status_code=200, + json={"foo": "bar"}, + headers={"content-type": "application/json; charset=utf8"}, + ), + ) + + assert self.http_client.request("ok_place") == {"foo": "bar"} diff --git a/tests/test_user_management.py b/tests/test_user_management.py index 9b40c8ec..75fd5e28 100644 --- a/tests/test_user_management.py +++ b/tests/test_user_management.py @@ -15,7 +15,7 @@ import workos from workos.user_management import UserManagement from workos.utils.http_client import SyncHTTPClient -from workos.utils.request import RESPONSE_TYPE_CODE +from workos.utils.request_helper import RESPONSE_TYPE_CODE class TestUserManagement(object): diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 6467d6a0..1d91243b 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -6,7 +6,7 @@ import pytest import workos from workos.webhooks import Webhooks -from workos.utils.request import RESPONSE_TYPE_CODE +from workos.utils.request_helper import RESPONSE_TYPE_CODE class TestWebhooks(object): diff --git a/tests/utils/test_request_helper.py b/tests/utils/test_request_helper.py new file mode 100644 index 00000000..724e39bd --- /dev/null +++ b/tests/utils/test_request_helper.py @@ -0,0 +1,13 @@ +from workos.utils.request_helper import RequestHelper + + +class TestRequestHelper: + + def test_build_parameterized_url(self): + assert RequestHelper.build_parameterized_url("a/b/c") == "a/b/c" + assert RequestHelper.build_parameterized_url("a/{b}/c", b="b") == "a/b/c" + assert RequestHelper.build_parameterized_url("a/{b}/c", b="test") == "a/test/c" + assert ( + RequestHelper.build_parameterized_url("a/{b}/c", b="i/am/being/sneaky") + == "a/i/am/being/sneaky/c" + ) diff --git a/tests/utils/test_requests.py b/tests/utils/test_requests.py deleted file mode 100644 index 4bbef560..00000000 --- a/tests/utils/test_requests.py +++ /dev/null @@ -1,156 +0,0 @@ -import pytest - -from workos.exceptions import ( - AuthenticationException, - AuthorizationException, - BadRequestException, - ServerException, -) -from workos.utils.request import RequestHelper, BASE_HEADERS - -STATUS_CODE_TO_EXCEPTION_MAPPING = { - 400: BadRequestException, - 401: AuthenticationException, - 403: AuthorizationException, - 500: ServerException, -} - - -class TestRequestHelper(object): - def test_set_base_api_url(self): - pass - - def test_request_raises_expected_exception_for_status_code( - self, mock_request_method - ): - request_helper = RequestHelper() - - for status_code, exception in STATUS_CODE_TO_EXCEPTION_MAPPING.items(): - mock_request_method("get", {}, status_code) - - with pytest.raises(exception): - request_helper.request("bad_place") - - def test_request_exceptions_include_expected_request_data( - self, mock_request_method - ): - request_helper = RequestHelper() - - request_id = "request-123" - response_message = "stuff happened" - - for status_code, exception in STATUS_CODE_TO_EXCEPTION_MAPPING.items(): - mock_request_method( - "get", - {"message": response_message}, - status_code, - headers={"X-Request-ID": request_id}, - ) - - try: - request_helper.request("bad_place") - except exception as ex: - assert ex.message == response_message - assert ex.request_id == request_id - except Exception as ex: - # This'll fail for sure here but... just using the nice error that'd come up - assert ex.__class__ == exception - - def test_bad_request_exceptions_include_expected_request_data( - self, mock_request_method - ): - request_helper = RequestHelper() - - request_id = "request-123" - error = "example_error" - error_description = "Example error description" - - mock_request_method( - "get", - {"error": error, "error_description": error_description}, - 400, - headers={"X-Request-ID": request_id}, - ) - - try: - request_helper.request("bad_place") - except BadRequestException as ex: - assert ( - str(ex) - == "(message=No message, request_id=request-123, error=example_error, error_description=Example error description)" - ) - except Exception as ex: - assert ex.__class__ == BadRequestException - - def test_bad_request_exceptions_exclude_expected_request_data( - self, mock_request_method - ): - request_helper = RequestHelper() - - request_id = "request-123" - - mock_request_method( - "get", - {"foo": "bar"}, - 400, - headers={"X-Request-ID": request_id}, - ) - - try: - request_helper.request("bad_place") - except BadRequestException as ex: - assert str(ex) == "(message=No message, request_id=request-123)" - except Exception as ex: - assert ex.__class__ == BadRequestException - - def test_request_bad_body_raises_expected_exception_with_request_data( - self, mock_request_method - ): - request_id = "request-123" - - mock_request_method( - "get", "this_isnt_json", 200, headers={"X-Request-ID": request_id} - ) - - try: - RequestHelper().request("bad_place") - except ServerException as ex: - assert ex.message == None - assert ex.request_id == request_id - except Exception as ex: - # This'll fail for sure here but... just using the nice error that'd come up - assert ex.__class__ == ServerException - - def test_request_includes_base_headers(self, capture_and_mock_request): - request_args, request_kwargs = capture_and_mock_request("get", {}, 200) - - RequestHelper().request("ok_place") - - base_headers = set(BASE_HEADERS.items()) - headers = set(request_kwargs["headers"].items()) - - assert base_headers.issubset(headers) - - def test_request_parses_json_when_content_type_present(self, mock_request_method): - mock_request_method( - "get", {"foo": "bar"}, 200, headers={"content-type": "application/json"} - ) - - assert RequestHelper().request("ok_place") == {"foo": "bar"} - - def test_request_parses_json_when_encoding_in_content_type( - self, mock_request_method - ): - mock_request_method( - "get", - {"foo": "bar"}, - 200, - headers={"content-type": "application/json; charset=utf8"}, - ) - - assert RequestHelper().request("ok_place") == {"foo": "bar"} - - def test_build_url(self): - assert RequestHelper.build_parameterized_url("a/b/c") == "a/b/c" - assert RequestHelper.build_parameterized_url("a/{b}/c", b="b") == "a/b/c" - assert RequestHelper.build_parameterized_url("a/{b}/c", b="test") == "a/test/c" diff --git a/workos/audit_logs.py b/workos/audit_logs.py index 43c9a52e..fd3456a8 100644 --- a/workos/audit_logs.py +++ b/workos/audit_logs.py @@ -3,7 +3,7 @@ import workos from workos.resources.audit_logs import AuditLogEvent, AuditLogExport from workos.utils.http_client import SyncHTTPClient -from workos.utils.request import REQUEST_METHOD_GET, REQUEST_METHOD_POST +from workos.utils.request_helper import REQUEST_METHOD_GET, REQUEST_METHOD_POST from workos.utils.validation import AUDIT_LOGS_MODULE, validate_settings EVENTS_PATH = "audit_logs/events" diff --git a/workos/directory_sync.py b/workos/directory_sync.py index 3d478769..2305feba 100644 --- a/workos/directory_sync.py +++ b/workos/directory_sync.py @@ -3,7 +3,7 @@ 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 import ( +from workos.utils.request_helper import ( DEFAULT_LIST_RESPONSE_LIMIT, REQUEST_METHOD_DELETE, REQUEST_METHOD_GET, diff --git a/workos/events.py b/workos/events.py index df8ead7d..fe97d4cb 100644 --- a/workos/events.py +++ b/workos/events.py @@ -2,7 +2,7 @@ import workos from workos.typing.sync_or_async import SyncOrAsync -from workos.utils.request import DEFAULT_LIST_RESPONSE_LIMIT, REQUEST_METHOD_GET +from workos.utils.request_helper import DEFAULT_LIST_RESPONSE_LIMIT, REQUEST_METHOD_GET from workos.resources.events import Event, EventType from workos.utils.http_client import AsyncHTTPClient, SyncHTTPClient from workos.utils.validation import EVENTS_MODULE, validate_settings diff --git a/workos/mfa.py b/workos/mfa.py index c0f974f2..2f11a748 100644 --- a/workos/mfa.py +++ b/workos/mfa.py @@ -2,7 +2,7 @@ import workos from workos.utils.http_client import SyncHTTPClient -from workos.utils.request import ( +from workos.utils.request_helper import ( REQUEST_METHOD_POST, REQUEST_METHOD_DELETE, REQUEST_METHOD_GET, diff --git a/workos/organizations.py b/workos/organizations.py index 584355dd..af10e680 100644 --- a/workos/organizations.py +++ b/workos/organizations.py @@ -2,7 +2,7 @@ import workos from workos.utils.http_client import SyncHTTPClient from workos.utils.pagination_order import PaginationOrder -from workos.utils.request import ( +from workos.utils.request_helper import ( DEFAULT_LIST_RESPONSE_LIMIT, REQUEST_METHOD_DELETE, REQUEST_METHOD_GET, diff --git a/workos/passwordless.py b/workos/passwordless.py index 21a1188a..e64f61f4 100644 --- a/workos/passwordless.py +++ b/workos/passwordless.py @@ -2,7 +2,7 @@ import workos from workos.utils.http_client import SyncHTTPClient -from workos.utils.request import REQUEST_METHOD_POST +from workos.utils.request_helper import REQUEST_METHOD_POST from workos.utils.validation import PASSWORDLESS_MODULE, validate_settings from workos.resources.passwordless import PasswordlessSession, PasswordlessSessionType diff --git a/workos/portal.py b/workos/portal.py index 9c0611d6..a1da0332 100644 --- a/workos/portal.py +++ b/workos/portal.py @@ -3,7 +3,7 @@ import workos from workos.resources.portal import PortalLink, PortalLinkIntent from workos.utils.http_client import SyncHTTPClient -from workos.utils.request import REQUEST_METHOD_POST +from workos.utils.request_helper import REQUEST_METHOD_POST from workos.utils.validation import PORTAL_MODULE, validate_settings diff --git a/workos/sso.py b/workos/sso.py index dd83634d..5c8f4d95 100644 --- a/workos/sso.py +++ b/workos/sso.py @@ -11,7 +11,7 @@ SsoProviderType, ) from workos.utils.connection_types import ConnectionType -from workos.utils.request import ( +from workos.utils.request_helper import ( DEFAULT_LIST_RESPONSE_LIMIT, RESPONSE_TYPE_CODE, REQUEST_METHOD_DELETE, diff --git a/workos/user_management.py b/workos/user_management.py index e925e757..c3e179f4 100644 --- a/workos/user_management.py +++ b/workos/user_management.py @@ -29,7 +29,7 @@ from workos.utils.http_client import AsyncHTTPClient, SyncHTTPClient from workos.utils.pagination_order import PaginationOrder from workos.utils.um_provider_types import UserManagementProviderType -from workos.utils.request import ( +from workos.utils.request_helper import ( DEFAULT_LIST_RESPONSE_LIMIT, RESPONSE_TYPE_CODE, REQUEST_METHOD_POST, diff --git a/workos/utils/_base_http_client.py b/workos/utils/_base_http_client.py index f2545148..7bebf547 100644 --- a/workos/utils/_base_http_client.py +++ b/workos/utils/_base_http_client.py @@ -19,7 +19,7 @@ NotFoundException, BadRequestException, ) -from workos.utils.request import REQUEST_METHOD_DELETE, REQUEST_METHOD_GET +from workos.utils.request_helper import REQUEST_METHOD_DELETE, REQUEST_METHOD_GET _HttpxClientT = TypeVar("_HttpxClientT", bound=Union[httpx.Client, httpx.AsyncClient]) diff --git a/workos/utils/http_client.py b/workos/utils/http_client.py index b1929c1e..1fab1a71 100644 --- a/workos/utils/http_client.py +++ b/workos/utils/http_client.py @@ -4,7 +4,7 @@ import httpx from workos.utils._base_http_client import BaseHTTPClient -from workos.utils.request import REQUEST_METHOD_GET +from workos.utils.request_helper import REQUEST_METHOD_GET class SyncHttpxClientWrapper(httpx.Client): diff --git a/workos/utils/request.py b/workos/utils/request.py deleted file mode 100644 index f621b7a7..00000000 --- a/workos/utils/request.py +++ /dev/null @@ -1,133 +0,0 @@ -import platform -from typing import Any -import urllib.parse - -import requests -import urllib - -import workos -from workos.exceptions import ( - AuthorizationException, - AuthenticationException, - BadRequestException, - NotFoundException, - ServerException, -) - -BASE_HEADERS = { - "User-Agent": "WorkOS Python/{} Python SDK/{}".format( - platform.python_version(), - workos.__version__, - ), -} - -DEFAULT_LIST_RESPONSE_LIMIT = 10 -RESPONSE_TYPE_CODE = "code" -REQUEST_METHOD_DELETE = "delete" -REQUEST_METHOD_GET = "get" -REQUEST_METHOD_POST = "post" -REQUEST_METHOD_PUT = "put" - - -class RequestHelper(object): - def __init__(self): - self.set_base_api_url(workos.base_api_url) - self.set_request_timeout(workos.request_timeout) - - def set_request_timeout(self, request_timeout): - self.request_timeout = request_timeout - - def set_base_api_url(self, base_api_url): - """Creates an accessible template for constructing the URL for an API request. - - Args: - base_api_url (str): Base URL for api requests (Should end with a /) - """ - self.base_api_url = "{}{{}}".format(base_api_url) - - def generate_api_url(self, path): - return self.base_api_url.format(path) - - @classmethod - def build_parameterized_url(cls, url, **params): - escaped_params = {k: urllib.parse.quote(str(v)) for k, v in params.items()} - return url.format(**escaped_params) - - @classmethod - def build_url_with_query_params(cls, base_url: str, path: str, **params): - return base_url.format(path) + "?" + urllib.parse.urlencode(params) - - def request( - self, - path, - method=REQUEST_METHOD_GET, - params=None, - headers=None, - token=None, - # TODO: This isn't quite true. There are paths where this may return None. - ) -> Any: - """Executes a request against the WorkOS API. - - Args: - path (str): Path for the api request that'd be appended to the base API URL - - Kwargs: - method (str): One of the supported methods as defined by the REQUEST_METHOD_X constants - params (dict): Query params or body payload to be added to the request - token (str): Bearer token - - Returns: - dict: Response from WorkOS - """ - if headers is None: - headers = {} - - if token: - headers["Authorization"] = "Bearer {}".format(token) - - headers.update(BASE_HEADERS) - url = self.generate_api_url(path) - - if method == REQUEST_METHOD_GET: - response = requests.request( - method, - url, - headers=headers, - params=params, - timeout=self.request_timeout, - ) - else: - response = requests.request( - method, url, headers=headers, json=params, timeout=self.request_timeout - ) - - response_json = None - content_type = ( - response.headers.get("content-type") - if response.headers is not None - else None - ) - if content_type is not None and "application/json" in content_type: - try: - response_json = response.json() - except ValueError: - raise ServerException(response) - - status_code = response.status_code - if status_code >= 400 and status_code < 500: - if status_code == 401: - raise AuthenticationException(response) - elif status_code == 403: - raise AuthorizationException(response) - elif status_code == 404: - raise NotFoundException(response) - if response_json is not None: - error = response_json.get("error") - error_description = response_json.get("error_description") - raise BadRequestException( - response, error=error, error_description=error_description - ) - elif status_code >= 500 and status_code < 600: - raise ServerException(response) - - return response_json diff --git a/workos/utils/request_helper.py b/workos/utils/request_helper.py new file mode 100644 index 00000000..968c8c41 --- /dev/null +++ b/workos/utils/request_helper.py @@ -0,0 +1,21 @@ +import urllib.parse + + +DEFAULT_LIST_RESPONSE_LIMIT = 10 +RESPONSE_TYPE_CODE = "code" +REQUEST_METHOD_DELETE = "delete" +REQUEST_METHOD_GET = "get" +REQUEST_METHOD_POST = "post" +REQUEST_METHOD_PUT = "put" + + +class RequestHelper: + + @classmethod + def build_parameterized_url(cls, url, **params): + escaped_params = {k: urllib.parse.quote(str(v)) for k, v in params.items()} + return url.format(**escaped_params) + + @classmethod + def build_url_with_query_params(cls, base_url: str, path: str, **params): + return base_url.format(path) + "?" + urllib.parse.urlencode(params) diff --git a/workos/webhooks.py b/workos/webhooks.py index 576b2dda..c97df151 100644 --- a/workos/webhooks.py +++ b/workos/webhooks.py @@ -1,6 +1,6 @@ from typing import Protocol -from workos.utils.request import RequestHelper +from workos.utils.request_helper import RequestHelper from workos.utils.validation import WEBHOOKS_MODULE, validate_settings import hmac import json From 205aed4c4e93a2d2957d5204695361bd21cf1f1d Mon Sep 17 00:00:00 2001 From: mattgd Date: Tue, 30 Jul 2024 13:50:08 -0400 Subject: [PATCH 17/18] Remove WorkOSBaseResource. --- tests/test_directory_sync.py | 22 ++-- tests/test_events.py | 16 +-- tests/test_organizations.py | 8 +- tests/test_sso.py | 94 ++++------------ tests/test_user_management.py | 28 ++--- tests/utils/fixtures/mock_auth_factor_totp.py | 43 ++++---- tests/utils/fixtures/mock_connection.py | 48 ++++----- tests/utils/fixtures/mock_directory.py | 40 +++---- .../mock_directory_activated_payload.py | 30 ------ tests/utils/fixtures/mock_directory_group.py | 37 +++---- tests/utils/fixtures/mock_directory_user.py | 102 ++++++++---------- .../utils/fixtures/mock_email_verification.py | 34 +++--- tests/utils/fixtures/mock_event.py | 42 ++++---- tests/utils/fixtures/mock_invitation.py | 49 +++------ tests/utils/fixtures/mock_magic_auth.py | 34 +++--- tests/utils/fixtures/mock_organization.py | 46 ++++---- .../fixtures/mock_organization_membership.py | 34 +++--- tests/utils/fixtures/mock_password_reset.py | 34 +++--- tests/utils/fixtures/mock_profile.py | 24 +++++ tests/utils/fixtures/mock_session.py | 41 ------- tests/utils/fixtures/mock_user.py | 37 +++---- workos/resources/base.py | 39 ------- workos/resources/event_action.py | 10 -- workos/resources/list.py | 94 +--------------- workos/user_management.py | 3 +- 25 files changed, 317 insertions(+), 672 deletions(-) delete mode 100644 tests/utils/fixtures/mock_directory_activated_payload.py create mode 100644 tests/utils/fixtures/mock_profile.py delete mode 100644 tests/utils/fixtures/mock_session.py delete mode 100644 workos/resources/base.py delete mode 100644 workos/resources/event_action.py diff --git a/tests/test_directory_sync.py b/tests/test_directory_sync.py index 5b36aa94..052fcad1 100644 --- a/tests/test_directory_sync.py +++ b/tests/test_directory_sync.py @@ -25,7 +25,7 @@ def api_directories_to_sdk(directories): class DirectorySyncFixtures: @pytest.fixture def mock_users(self): - user_list = [MockDirectoryUser(id=str(i)).to_dict() for i in range(100)] + user_list = [MockDirectoryUser(id=str(i)).dict() for i in range(100)] return { "data": user_list, @@ -35,7 +35,7 @@ def mock_users(self): @pytest.fixture def mock_groups(self): - group_list = [MockDirectoryGroup(id=str(i)).to_dict() for i in range(20)] + group_list = [MockDirectoryGroup(id=str(i)).dict() for i in range(20)] return list_response_of(data=group_list, after="xxx") @pytest.fixture @@ -44,7 +44,7 @@ def mock_user_primary_email(self): @pytest.fixture def mock_user(self): - return MockDirectoryUser("directory_user_01E1JG7J09H96KYP8HM9B0G5SJ").to_dict() + return MockDirectoryUser("directory_user_01E1JG7J09H96KYP8HM9B0G5SJ").dict() @pytest.fixture def mock_user_no_email(self): @@ -81,36 +81,32 @@ def mock_user_no_email(self): @pytest.fixture def mock_group(self): - return MockDirectoryGroup( - "directory_group_01FHGRYAQ6ERZXXXXXX1E01QFE" - ).to_dict() + return MockDirectoryGroup("directory_group_01FHGRYAQ6ERZXXXXXX1E01QFE").dict() @pytest.fixture def mock_directories(self): - directory_list = [MockDirectory(id=str(i)).to_dict() for i in range(10)] + directory_list = [MockDirectory(id=str(i)).dict() for i in range(10)] return list_response_of(data=directory_list) @pytest.fixture def mock_directory_users_multiple_data_pages(self): return [ - MockDirectoryUser(id=str(f"directory_user_{i}")).to_dict() - for i in range(40) + MockDirectoryUser(id=str(f"directory_user_{i}")).dict() for i in range(40) ] @pytest.fixture def mock_directories_multiple_data_pages(self): - return [MockDirectory(id=str(f"dir_{i}")).to_dict() for i in range(40)] + return [MockDirectory(id=str(f"dir_{i}")).dict() for i in range(40)] @pytest.fixture def mock_directory_groups_multiple_data_pages(self): return [ - MockDirectoryGroup(id=str(f"directory_group_{i}")).to_dict() - for i in range(40) + MockDirectoryGroup(id=str(f"directory_group_{i}")).dict() for i in range(40) ] @pytest.fixture def mock_directory(self): - return MockDirectory("directory_id").to_dict() + return MockDirectory("directory_id").dict() class TestDirectorySync(DirectorySyncFixtures): diff --git a/tests/test_events.py b/tests/test_events.py index bbe6bd68..0a1a0f4f 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -7,11 +7,7 @@ class TestEvents(object): @pytest.fixture(autouse=True) - def setup( - self, - set_api_key, - set_client_id, - ): + def setup(self, set_api_key, set_client_id): self.http_client = SyncHTTPClient( base_url="https://api.workos.test", version="test" ) @@ -19,7 +15,7 @@ def setup( @pytest.fixture def mock_events(self): - events = [MockEvent(id=str(i)).to_dict() for i in range(10)] + events = [MockEvent(id=str(i)).dict() for i in range(10)] return { "object": "list", @@ -44,11 +40,7 @@ def test_list_events(self, mock_events, mock_http_client_with_response): @pytest.mark.asyncio class TestAsyncEvents(object): @pytest.fixture(autouse=True) - def setup( - self, - set_api_key, - set_client_id, - ): + def setup(self, set_api_key, set_client_id): self.http_client = AsyncHTTPClient( base_url="https://api.workos.test", version="test" ) @@ -56,7 +48,7 @@ def setup( @pytest.fixture def mock_events(self): - events = [MockEvent(id=str(i)).to_dict() for i in range(10)] + events = [MockEvent(id=str(i)).dict() for i in range(10)] return { "object": "list", diff --git a/tests/test_organizations.py b/tests/test_organizations.py index d06ec3fe..8171076b 100644 --- a/tests/test_organizations.py +++ b/tests/test_organizations.py @@ -18,7 +18,7 @@ def setup(self, set_api_key): @pytest.fixture def mock_organization(self): - return MockOrganization("org_01EHT88Z8J8795GZNQ4ZP1J81T").to_dict() + return MockOrganization("org_01EHT88Z8J8795GZNQ4ZP1J81T").dict() @pytest.fixture def mock_organization_updated(self): @@ -40,7 +40,7 @@ def mock_organization_updated(self): @pytest.fixture def mock_organizations(self): - organization_list = [MockOrganization(id=str(i)).to_dict() for i in range(10)] + organization_list = [MockOrganization(id=str(i)).dict() for i in range(10)] return { "data": organization_list, @@ -50,7 +50,7 @@ def mock_organizations(self): @pytest.fixture def mock_organizations_single_page_response(self): - organization_list = [MockOrganization(id=str(i)).to_dict() for i in range(10)] + organization_list = [MockOrganization(id=str(i)).dict() for i in range(10)] return { "data": organization_list, "list_metadata": {"before": None, "after": None}, @@ -60,7 +60,7 @@ def mock_organizations_single_page_response(self): @pytest.fixture def mock_organizations_multiple_data_pages(self): organizations_list = [ - MockOrganization(id=str(f"org_{i+1}")).to_dict() for i in range(40) + MockOrganization(id=str(f"org_{i+1}")).dict() for i in range(40) ] return list_response_of(data=organizations_list) diff --git a/tests/test_sso.py b/tests/test_sso.py index 0a82316c..7376deba 100644 --- a/tests/test_sso.py +++ b/tests/test_sso.py @@ -3,10 +3,11 @@ from six.moves.urllib.parse import parse_qsl, urlparse import pytest +from tests.utils.fixtures.mock_profile import MockProfile from tests.utils.list_resource import list_data_to_dicts, list_response_of import workos from workos.sso import SSO, AsyncSSO -from workos.resources.sso import SsoProviderType +from workos.resources.sso import Profile, SsoProviderType from workos.utils.http_client import AsyncHTTPClient, SyncHTTPClient from workos.utils.request_helper import RESPONSE_TYPE_CODE from tests.utils.fixtures.mock_connection import MockConnection @@ -15,54 +16,37 @@ class SSOFixtures: @pytest.fixture def mock_profile(self): - return { - "object": "profile", - "id": "prof_01DWAS7ZQWM70PV93BFV1V78QV", - "email": "demo@workos-okta.com", - "first_name": "WorkOS", - "last_name": "Demo", - "groups": ["Admins", "Developers"], - "organization_id": "org_01FG53X8636WSNW2WEKB2C31ZB", - "connection_id": "conn_01EMH8WAK20T42N2NBMNBCYHAG", - "connection_type": "OktaSAML", - "idp_id": "00u1klkowm8EGah2H357", - "raw_attributes": { - "email": "demo@workos-okta.com", - "first_name": "WorkOS", - "last_name": "Demo", - "groups": ["Admins", "Developers"], - }, - } + return MockProfile("prof_01DWAS7ZQWM70PV93BFV1V78QV").dict() @pytest.fixture def mock_magic_link_profile(self): - return { - "object": "profile", - "id": "prof_01DWAS7ZQWM70PV93BFV1V78QV", - "email": "demo@workos-magic-link.com", - "organization_id": None, - "connection_id": "conn_01EMH8WAK20T42N2NBMNBCYHAG", - "connection_type": "MagicLink", - "idp_id": "", - "first_name": None, - "last_name": None, - "groups": None, - "raw_attributes": {}, - } + return Profile( + object="profile", + id="prof_01DWAS7ZQWM70PV93BFV1V78QV", + email="demo@workos-magic-link.com", + organization_id=None, + connection_id="conn_01EMH8WAK20T42N2NBMNBCYHAG", + connection_type="MagicLink", + idp_id="", + first_name=None, + last_name=None, + groups=None, + raw_attributes={}, + ).dict() @pytest.fixture def mock_connection(self): - return MockConnection("conn_01E4ZCR3C56J083X43JQXF3JK5").to_dict() + return MockConnection("conn_01E4ZCR3C56J083X43JQXF3JK5").dict() @pytest.fixture def mock_connections(self): - connection_list = [MockConnection(id=str(i)).to_dict() for i in range(10)] + connection_list = [MockConnection(id=str(i)).dict() for i in range(10)] return list_response_of(data=connection_list) @pytest.fixture def mock_connections_multiple_data_pages(self): - return [MockConnection(id=str(i)).to_dict() for i in range(40)] + return [MockConnection(id=str(i)).dict() for i in range(40)] class TestSSOBase(SSOFixtures): @@ -252,24 +236,7 @@ def test_get_profile_and_token_returns_expected_profile_object( self, mock_profile, mock_http_client_with_response ): response_dict = { - "profile": { - "object": "profile", - "id": mock_profile["id"], - "email": mock_profile["email"], - "first_name": mock_profile["first_name"], - "groups": mock_profile["groups"], - "organization_id": mock_profile["organization_id"], - "connection_id": mock_profile["connection_id"], - "connection_type": mock_profile["connection_type"], - "last_name": mock_profile["last_name"], - "idp_id": mock_profile["idp_id"], - "raw_attributes": { - "email": mock_profile["raw_attributes"]["email"], - "first_name": mock_profile["raw_attributes"]["first_name"], - "last_name": mock_profile["raw_attributes"]["last_name"], - "groups": mock_profile["raw_attributes"]["groups"], - }, - }, + "profile": mock_profile, "access_token": "01DY34ACQTM3B1CSX1YSZ8Z00D", } @@ -387,27 +354,10 @@ def setup(self, set_api_key_and_client_id): self.setup_completed = True async def test_get_profile_and_token_returns_expected_profile_object( - self, mock_profile, mock_http_client_with_response + self, mock_profile: Profile, mock_http_client_with_response ): response_dict = { - "profile": { - "object": "profile", - "id": mock_profile["id"], - "email": mock_profile["email"], - "first_name": mock_profile["first_name"], - "groups": mock_profile["groups"], - "organization_id": mock_profile["organization_id"], - "connection_id": mock_profile["connection_id"], - "connection_type": mock_profile["connection_type"], - "last_name": mock_profile["last_name"], - "idp_id": mock_profile["idp_id"], - "raw_attributes": { - "email": mock_profile["raw_attributes"]["email"], - "first_name": mock_profile["raw_attributes"]["first_name"], - "last_name": mock_profile["raw_attributes"]["last_name"], - "groups": mock_profile["raw_attributes"]["groups"], - }, - }, + "profile": mock_profile, "access_token": "01DY34ACQTM3B1CSX1YSZ8Z00D", } diff --git a/tests/test_user_management.py b/tests/test_user_management.py index 75fd5e28..d00fa1f2 100644 --- a/tests/test_user_management.py +++ b/tests/test_user_management.py @@ -3,7 +3,7 @@ from six.moves.urllib.parse import parse_qsl, urlparse import pytest -from tests.utils.fixtures.mock_auth_factor_totp import MockAuthFactorTotp +from tests.utils.fixtures.mock_auth_factor_totp import MockAuthenticationFactorTotp from tests.utils.fixtures.mock_email_verification import MockEmailVerification from tests.utils.fixtures.mock_invitation import MockInvitation from tests.utils.fixtures.mock_magic_auth import MockMagicAuth @@ -28,27 +28,27 @@ def setup(self, set_api_key, set_client_id): @pytest.fixture def mock_user(self): - return MockUser("user_01H7ZGXFP5C6BBQY6Z7277ZCT0").to_dict() + return MockUser("user_01H7ZGXFP5C6BBQY6Z7277ZCT0").dict() @pytest.fixture def mock_users_multiple_pages(self): - users_list = [MockUser(id=str(i)).to_dict() for i in range(40)] + users_list = [MockUser(id=str(i)).dict() for i in range(40)] return list_response_of(data=users_list) @pytest.fixture def mock_organization_membership(self): - return MockOrganizationMembership("om_ABCDE").to_dict() + return MockOrganizationMembership("om_ABCDE").dict() @pytest.fixture def mock_organization_memberships_multiple_pages(self): organization_memberships_list = [ - MockOrganizationMembership(id=str(i)).to_dict() for i in range(40) + MockOrganizationMembership(id=str(i)).dict() for i in range(40) ] return list_response_of(data=organization_memberships_list) @pytest.fixture def mock_auth_response(self): - user = MockUser("user_01H7ZGXFP5C6BBQY6Z7277ZCT0").to_dict() + user = MockUser("user_01H7ZGXFP5C6BBQY6Z7277ZCT0").dict() return { "user": user, @@ -73,7 +73,7 @@ def mock_auth_refresh_token_response(self): @pytest.fixture def mock_auth_response_with_impersonator(self): - user = MockUser("user_01H7ZGXFP5C6BBQY6Z7277ZCT0").to_dict() + user = MockUser("user_01H7ZGXFP5C6BBQY6Z7277ZCT0").dict() return { "user": user, @@ -123,28 +123,30 @@ def mock_enroll_auth_factor_response(self): @pytest.fixture def mock_auth_factors_multiple_pages(self): - auth_factors_list = [MockAuthFactorTotp(id=str(i)).to_dict() for i in range(40)] + auth_factors_list = [ + MockAuthenticationFactorTotp(id=str(i)).dict() for i in range(40) + ] return list_response_of(data=auth_factors_list) @pytest.fixture def mock_email_verification(self): - return MockEmailVerification("email_verification_ABCDE").to_dict() + return MockEmailVerification("email_verification_ABCDE").dict() @pytest.fixture def mock_magic_auth(self): - return MockMagicAuth("magic_auth_ABCDE").to_dict() + return MockMagicAuth("magic_auth_ABCDE").dict() @pytest.fixture def mock_password_reset(self): - return MockPasswordReset("password_reset_ABCDE").to_dict() + return MockPasswordReset("password_reset_ABCDE").dict() @pytest.fixture def mock_invitation(self): - return MockInvitation("invitation_ABCDE").to_dict() + return MockInvitation("invitation_ABCDE").dict() @pytest.fixture def mock_invitations_multiple_pages(self): - invitations_list = [MockInvitation(id=str(i)).to_dict() for i in range(40)] + invitations_list = [MockInvitation(id=str(i)).dict() for i in range(40)] return list_response_of(data=invitations_list) def test_get_user(self, mock_user, capture_and_mock_http_client_request): diff --git a/tests/utils/fixtures/mock_auth_factor_totp.py b/tests/utils/fixtures/mock_auth_factor_totp.py index 9d218bac..e581ce07 100644 --- a/tests/utils/fixtures/mock_auth_factor_totp.py +++ b/tests/utils/fixtures/mock_auth_factor_totp.py @@ -1,30 +1,23 @@ import datetime -from workos.resources.base import WorkOSBaseResource +from workos.resources.mfa import AuthenticationFactorTotp, ExtendedTotpFactor -class MockAuthFactorTotp(WorkOSBaseResource): + +class MockAuthenticationFactorTotp(AuthenticationFactorTotp): def __init__(self, id): now = datetime.datetime.now().isoformat() - self.object = "authentication_factor" - self.id = id - self.created_at = now - self.updated_at = now - self.type = "totp" - self.user_id = "user_123" - self.totp = { - "issuer": "FooCorp", - "user": "test@example.com", - "qr_code": "data:image/png;base64,{base64EncodedPng}", - "secret": "NAGCCFS3EYRB422HNAKAKY3XDUORMSRF", - "uri": "otpauth://totp/FooCorp:alan.turing@foo-corp.com?secret=NAGCCFS3EYRB422HNAKAKY3XDUORMSRF&issuer=FooCorp", - } - - OBJECT_FIELDS = [ - "object", - "id", - "created_at", - "updated_at", - "type", - "totp", - "user_id", - ] + super().__init__( + object="authentication_factor", + id=id, + created_at=now, + updated_at=now, + type="totp", + user_id="user_123", + totp=ExtendedTotpFactor( + issuer="FooCorp", + user="test@example.com", + qr_code="data:image/png;base64,{base64EncodedPng}", + secret="NAGCCFS3EYRB422HNAKAKY3XDUORMSRF", + uri="otpauth://totp/FooCorp:alan.turing@foo-corp.com?secret=NAGCCFS3EYRB422HNAKAKY3XDUORMSRF&issuer=FooCorp", + ), + ) diff --git a/tests/utils/fixtures/mock_connection.py b/tests/utils/fixtures/mock_connection.py index 5f742693..63c73fa5 100644 --- a/tests/utils/fixtures/mock_connection.py +++ b/tests/utils/fixtures/mock_connection.py @@ -1,34 +1,24 @@ import datetime -from workos.resources.base import WorkOSBaseResource +from workos.resources.sso import ConnectionDomain, ConnectionWithDomains -class MockConnection(WorkOSBaseResource): +class MockConnection(ConnectionWithDomains): def __init__(self, id): now = datetime.datetime.now().isoformat() - self.object = "connection" - self.id = id - self.organization_id = "org_id_" + id - self.connection_type = "OktaSAML" - self.name = "Foo Corporation" - self.state = "active" - self.created_at = now - self.updated_at = now - self.domains = [ - { - "id": "connection_domain_abc123", - "object": "connection_domain", - "domain": "domain1.com", - } - ] - - OBJECT_FIELDS = [ - "object", - "id", - "organization_id", - "connection_type", - "name", - "state", - "created_at", - "updated_at", - "domains", - ] + super().__init__( + object="connection", + id=id, + organization_id="org_id_" + id, + connection_type="OktaSAML", + name="Foo Corporation", + state="active", + created_at=now, + updated_at=now, + domains=[ + ConnectionDomain( + id="connection_domain_abc123", + object="connection_domain", + domain="domain1.com", + ) + ], + ) diff --git a/tests/utils/fixtures/mock_directory.py b/tests/utils/fixtures/mock_directory.py index 7abf6da4..8e801efe 100644 --- a/tests/utils/fixtures/mock_directory.py +++ b/tests/utils/fixtures/mock_directory.py @@ -1,30 +1,20 @@ import datetime -from workos.resources.base import WorkOSBaseResource +from workos.resources.directory_sync import Directory -class MockDirectory(WorkOSBaseResource): + +class MockDirectory(Directory): def __init__(self, id): now = datetime.datetime.now().isoformat() - self.object = "directory" - self.id = id - self.organization_id = "organization_id" - self.external_key = "ext_123" - self.domain = "somefakedomain.com" - self.name = "Some fake name" - self.state = "linked" - self.type = "gsuite directory" - self.created_at = now - self.updated_at = now - - OBJECT_FIELDS = [ - "object", - "id", - "domain", - "name", - "external_key", - "organization_id", - "state", - "type", - "created_at", - "updated_at", - ] + super().__init__( + object="directory", + id=id, + organization_id="organization_id", + external_key="ext_123", + domain="somefakedomain.com", + name="Some fake name", + state="active", + type="gsuite directory", + created_at=now, + updated_at=now, + ) diff --git a/tests/utils/fixtures/mock_directory_activated_payload.py b/tests/utils/fixtures/mock_directory_activated_payload.py deleted file mode 100644 index b4a0eeb8..00000000 --- a/tests/utils/fixtures/mock_directory_activated_payload.py +++ /dev/null @@ -1,30 +0,0 @@ -import datetime -from workos.resources.base import WorkOSBaseResource - - -class MockDirectoryActivatedPayload(WorkOSBaseResource): - def __init__(self, id): - now = datetime.datetime.now().isoformat() - self.object = "directory" - self.id = id - self.organization_id = "organization_id" - self.external_key = "ext_123" - self.domains = [] - self.name = "Some fake name" - self.state = "active" - self.type = "gsuite directory" - self.created_at = now - self.updated_at = now - - OBJECT_FIELDS = [ - "object", - "id", - "name", - "external_key", - "domains", - "organization_id", - "state", - "type", - "created_at", - "updated_at", - ] diff --git a/tests/utils/fixtures/mock_directory_group.py b/tests/utils/fixtures/mock_directory_group.py index 20229c5e..57f3b66d 100644 --- a/tests/utils/fixtures/mock_directory_group.py +++ b/tests/utils/fixtures/mock_directory_group.py @@ -1,28 +1,19 @@ import datetime -from workos.resources.base import WorkOSBaseResource +from workos.resources.directory_sync import DirectoryGroup -class MockDirectoryGroup(WorkOSBaseResource): + +class MockDirectoryGroup(DirectoryGroup): def __init__(self, id): now = datetime.datetime.now().isoformat() - self.id = id - self.idp_id = "idp_id_" + id - self.directory_id = "directory_id" - self.organization_id = "organization_id" - self.name = "group_" + id - self.created_at = now - self.updated_at = now - self.raw_attributes = {} - self.object = "directory_group" - - OBJECT_FIELDS = [ - "id", - "idp_id", - "directory_id", - "organization_id", - "name", - "created_at", - "updated_at", - "raw_attributes", - "object", - ] + super().__init__( + object="directory_group", + id=id, + idp_id="idp_id_" + id, + directory_id="directory_id", + organization_id="organization_id", + name="group_" + id, + created_at=now, + updated_at=now, + raw_attributes={}, + ) diff --git a/tests/utils/fixtures/mock_directory_user.py b/tests/utils/fixtures/mock_directory_user.py index 5e2f57ea..b58d3337 100644 --- a/tests/utils/fixtures/mock_directory_user.py +++ b/tests/utils/fixtures/mock_directory_user.py @@ -1,63 +1,51 @@ import datetime -from workos.resources.base import WorkOSBaseResource +from workos.resources.directory_sync import DirectoryUserWithGroups +from workos.types.directory_sync.directory_user import DirectoryUserEmail, InlineRole -class MockDirectoryUser(WorkOSBaseResource): + +class MockDirectoryUser(DirectoryUserWithGroups): def __init__(self, id): now = datetime.datetime.now().isoformat() - self.id = id - self.idp_id = "idp_id_" + id - self.directory_id = "directory_id" - self.organization_id = "org_id_" + id - self.first_name = "gsuite_directory" - self.last_name = "fried chicken" - self.job_title = "developer" - self.emails = [ - {"primary": True, "type": "work", "value": "marcelina@foo-corp.com"} - ] - self.username = None - self.groups = [] - self.state = "active" - self.created_at = now - self.updated_at = now - self.custom_attributes = {} - self.raw_attributes = { - "schemas": ["urn:scim:schemas:core:1.0"], - "name": {"familyName": "Seri", "givenName": "Marcelina"}, - "externalId": "external-id", - "locale": "en_US", - "userName": "marcelina@foo-corp.com", - "id": "directory_usr_id", - "displayName": "Marcelina Seri", - "title": "developer", - "active": True, - "groups": [], - "meta": None, - "emails": [ - {"value": "marcelina@foo-corp.com", "type": "work", "primary": "true"} + super().__init__( + object="directory_user", + id=id, + idp_id="idp_id_" + id, + directory_id="directory_id", + organization_id="org_id_" + id, + first_name="gsuite_directory", + last_name="fried chicken", + job_title="developer", + emails=[ + DirectoryUserEmail( + primary=True, type="work", value="marcelina@foo-corp.com" + ) ], - } - self.object = "directory_user" - self.role = { - "slug": "member", - } - - OBJECT_FIELDS = [ - "id", - "idp_id", - "directory_id", - "organization_id", - "first_name", - "last_name", - "job_title", - "emails", - "username", - "groups", - "state", - "created_at", - "updated_at", - "custom_attributes", - "raw_attributes", - "object", - "role", - ] + username=None, + groups=[], + state="active", + created_at=now, + updated_at=now, + custom_attributes={}, + raw_attributes={ + "schemas": ["urn:scim:schemas:core:1.0"], + "name": {"familyName": "Seri", "givenName": "Marcelina"}, + "externalId": "external-id", + "locale": "en_US", + "userName": "marcelina@foo-corp.com", + "id": "directory_usr_id", + "displayName": "Marcelina Seri", + "title": "developer", + "active": True, + "groups": [], + "meta": None, + "emails": [ + { + "value": "marcelina@foo-corp.com", + "type": "work", + "primary": "true", + } + ], + }, + role=InlineRole(slug="member"), + ) diff --git a/tests/utils/fixtures/mock_email_verification.py b/tests/utils/fixtures/mock_email_verification.py index 73f5f3b7..79fba524 100644 --- a/tests/utils/fixtures/mock_email_verification.py +++ b/tests/utils/fixtures/mock_email_verification.py @@ -1,26 +1,18 @@ import datetime -from workos.resources.base import WorkOSBaseResource +from workos.resources.user_management import EmailVerification -class MockEmailVerification(WorkOSBaseResource): + +class MockEmailVerification(EmailVerification): def __init__(self, id): now = datetime.datetime.now().isoformat() - self.object = "email_verification" - self.id = id - self.user_id = "user_01HWZBQAY251RZ9BKB4RZW4D4A" - self.email = "marcelina@foo-corp.com" - self.expires_at = now - self.code = "123456" - self.created_at = now - self.updated_at = now - - OBJECT_FIELDS = [ - "object", - "id", - "user_id", - "email", - "expires_at", - "code", - "created_at", - "updated_at", - ] + super().__init__( + object="email_verification", + id=id, + user_id="user_01HWZBQAY251RZ9BKB4RZW4D4A", + email="marcelina@foo-corp.com", + expires_at=now, + code="123456", + created_at=now, + updated_at=now, + ) diff --git a/tests/utils/fixtures/mock_event.py b/tests/utils/fixtures/mock_event.py index 1379f6d5..f64a690c 100644 --- a/tests/utils/fixtures/mock_event.py +++ b/tests/utils/fixtures/mock_event.py @@ -1,23 +1,29 @@ import datetime -from tests.utils.fixtures.mock_directory import MockDirectory -from tests.utils.fixtures.mock_directory_activated_payload import ( - MockDirectoryActivatedPayload, + +from workos.resources.events import DirectoryActivatedEvent +from workos.types.events.directory_payload_with_legacy_fields import ( + DirectoryPayloadWithLegacyFields, ) -from workos.resources.base import WorkOSBaseResource -class MockEvent(WorkOSBaseResource): +class MockEvent(DirectoryActivatedEvent): def __init__(self, id): - self.object = "event" - self.id = id - self.event = "dsync.activated" - self.data = MockDirectoryActivatedPayload("dir_1234").to_dict() - self.created_at = datetime.datetime.now().isoformat() - - OBJECT_FIELDS = [ - "object", - "id", - "event", - "data", - "created_at", - ] + now = datetime.datetime.now().isoformat() + super().__init__( + object="event", + id=id, + event="dsync.activated", + data=DirectoryPayloadWithLegacyFields( + object="directory", + id="dir_1234", + organization_id="organization_id", + external_key="ext_123", + domains=[], + name="Some fake name", + state="active", + type="gsuite directory", + created_at=now, + updated_at=now, + ), + created_at=now, + ) diff --git a/tests/utils/fixtures/mock_invitation.py b/tests/utils/fixtures/mock_invitation.py index ae823555..ddda96d2 100644 --- a/tests/utils/fixtures/mock_invitation.py +++ b/tests/utils/fixtures/mock_invitation.py @@ -1,38 +1,23 @@ import datetime -from workos.resources.base import WorkOSBaseResource +from workos.resources.user_management import Invitation -class MockInvitation(WorkOSBaseResource): + +class MockInvitation(Invitation): def __init__(self, id): now = datetime.datetime.now().isoformat() - self.object = "invitation" - self.id = id - self.email = "marcelina@foo-corp.com" - self.state = "pending" - self.accepted_at = None - self.revoked_at = None - self.expires_at = now - self.token = "Z1uX3RbwcIl5fIGJJJCXXisdI" - self.accept_invitation_url = ( - "https://your-app.com/invite?invitation_token=Z1uX3RbwcIl5fIGJJJCXXisdI" + super().__init__( + object="invitation", + id=id, + email="marcelina@foo-corp.com", + state="pending", + accepted_at=None, + revoked_at=None, + expires_at=now, + token="Z1uX3RbwcIl5fIGJJJCXXisdI", + accept_invitation_url="https://your-app.com/invite?invitation_token=Z1uX3RbwcIl5fIGJJJCXXisdI", + organization_id="org_12345", + inviter_user_id="user_123", + created_at=now, + updated_at=now, ) - self.organization_id = "org_12345" - self.inviter_user_id = "user_123" - self.created_at = now - self.updated_at = now - - OBJECT_FIELDS = [ - "object", - "id", - "email", - "state", - "accepted_at", - "revoked_at", - "expires_at", - "token", - "accept_invitation_url", - "organization_id", - "inviter_user_id", - "created_at", - "updated_at", - ] diff --git a/tests/utils/fixtures/mock_magic_auth.py b/tests/utils/fixtures/mock_magic_auth.py index fd51c951..f2d7d5ec 100644 --- a/tests/utils/fixtures/mock_magic_auth.py +++ b/tests/utils/fixtures/mock_magic_auth.py @@ -1,26 +1,18 @@ import datetime -from workos.resources.base import WorkOSBaseResource +from workos.resources.user_management import MagicAuth -class MockMagicAuth(WorkOSBaseResource): + +class MockMagicAuth(MagicAuth): def __init__(self, id): now = datetime.datetime.now().isoformat() - self.object = "magic_auth" - self.id = id - self.user_id = "user_01HWZBQAY251RZ9BKB4RZW4D4A" - self.email = "marcelina@foo-corp.com" - self.expires_at = now - self.code = "123456" - self.created_at = now - self.updated_at = now - - OBJECT_FIELDS = [ - "object", - "id", - "user_id", - "email", - "expires_at", - "code", - "created_at", - "updated_at", - ] + super().__init__( + object="magic_auth", + id=id, + user_id="user_01HWZBQAY251RZ9BKB4RZW4D4A", + email="marcelina@foo-corp.com", + expires_at=now, + code="123456", + created_at=now, + updated_at=now, + ) diff --git a/tests/utils/fixtures/mock_organization.py b/tests/utils/fixtures/mock_organization.py index 8388dc27..31091427 100644 --- a/tests/utils/fixtures/mock_organization.py +++ b/tests/utils/fixtures/mock_organization.py @@ -1,31 +1,25 @@ import datetime -from workos.resources.base import WorkOSBaseResource +from workos.resources.organizations import Organization +from workos.types.organizations.organization_domain import OrganizationDomain -class MockOrganization(WorkOSBaseResource): + +class MockOrganization(Organization): def __init__(self, id): now = datetime.datetime.now().isoformat() - self.id = id - self.object = "organization" - self.name = "Foo Corporation" - self.allow_profiles_outside_organization = False - self.created_at = now - self.updated_at = now - self.domains = [ - { - "domain": "example.io", - "object": "organization_domain", - "id": "org_domain_01EHT88Z8WZEFWYPM6EC9BX2R8", - } - ] - - OBJECT_FIELDS = [ - "id", - "object", - "name", - "allow_profiles_outside_organization", - "created_at", - "updated_at", - "domains", - "lookup_key", - ] + super().__init__( + object="organization", + id=id, + name="Foo Corporation", + allow_profiles_outside_organization=False, + created_at=now, + updated_at=now, + domains=[ + OrganizationDomain( + object="organization_domain", + id="org_domain_01EHT88Z8WZEFWYPM6EC9BX2R8", + organization_id="org_12345", + domain="example.io", + ) + ], + ) diff --git a/tests/utils/fixtures/mock_organization_membership.py b/tests/utils/fixtures/mock_organization_membership.py index 83937cbf..657201c8 100644 --- a/tests/utils/fixtures/mock_organization_membership.py +++ b/tests/utils/fixtures/mock_organization_membership.py @@ -1,26 +1,18 @@ import datetime -from workos.resources.base import WorkOSBaseResource +from workos.resources.user_management import OrganizationMembership -class MockOrganizationMembership(WorkOSBaseResource): + +class MockOrganizationMembership(OrganizationMembership): def __init__(self, id): now = datetime.datetime.now().isoformat() - self.object = "organization_membership" - self.id = id - self.user_id = "user_12345" - self.organization_id = "org_67890" - self.status = "active" - self.role = {"slug": "member"} - self.created_at = now - self.updated_at = now - - OBJECT_FIELDS = [ - "object", - "id", - "user_id", - "organization_id", - "status", - "role", - "created_at", - "updated_at", - ] + super().__init__( + object="organization_membership", + id=id, + user_id="user_12345", + organization_id="org_67890", + status="active", + role={"slug": "member"}, + created_at=now, + updated_at=now, + ) diff --git a/tests/utils/fixtures/mock_password_reset.py b/tests/utils/fixtures/mock_password_reset.py index ac05d0c6..34d27e58 100644 --- a/tests/utils/fixtures/mock_password_reset.py +++ b/tests/utils/fixtures/mock_password_reset.py @@ -1,28 +1,18 @@ import datetime -from workos.resources.base import WorkOSBaseResource +from workos.resources.user_management import PasswordReset -class MockPasswordReset(WorkOSBaseResource): + +class MockPasswordReset(PasswordReset): def __init__(self, id): now = datetime.datetime.now().isoformat() - self.object = "password_reset" - self.id = id - self.user_id = "user_01HWZBQAY251RZ9BKB4RZW4D4A" - self.email = "marcelina@foo-corp.com" - self.password_reset_token = "Z1uX3RbwcIl5fIGJJJCXXisdI" - self.password_reset_url = ( - "https://your-app.com/reset-password?token=Z1uX3RbwcIl5fIGJJJCXXisdI" + super().__init__( + object="password_reset", + id=id, + user_id="user_01HWZBQAY251RZ9BKB4RZW4D4A", + email="marcelina@foo-corp.com", + password_reset_token="Z1uX3RbwcIl5fIGJJJCXXisdI", + password_reset_url="https://your-app.com/reset-password?token=Z1uX3RbwcIl5fIGJJJCXXisdI", + expires_at=now, + created_at=now, ) - self.expires_at = now - self.created_at = now - - OBJECT_FIELDS = [ - "object", - "id", - "user_id", - "email", - "password_reset_token", - "password_reset_url", - "expires_at", - "created_at", - ] diff --git a/tests/utils/fixtures/mock_profile.py b/tests/utils/fixtures/mock_profile.py new file mode 100644 index 00000000..7f147566 --- /dev/null +++ b/tests/utils/fixtures/mock_profile.py @@ -0,0 +1,24 @@ +from workos.resources.sso import Profile + + +class MockProfile(Profile): + + def __init__(self, id: str): + super().__init__( + object="profile", + id="prof_01DWAS7ZQWM70PV93BFV1V78QV", + email="demo@workos-okta.com", + first_name="WorkOS", + last_name="Demo", + groups=["Admins", "Developers"], + organization_id="org_01FG53X8636WSNW2WEKB2C31ZB", + connection_id="conn_01EMH8WAK20T42N2NBMNBCYHAG", + connection_type="OktaSAML", + idp_id="00u1klkowm8EGah2H357", + raw_attributes={ + "email": "demo@workos-okta.com", + "first_name": "WorkOS", + "last_name": "Demo", + "groups": ["Admins", "Developers"], + }, + ) diff --git a/tests/utils/fixtures/mock_session.py b/tests/utils/fixtures/mock_session.py deleted file mode 100644 index 64c5eccd..00000000 --- a/tests/utils/fixtures/mock_session.py +++ /dev/null @@ -1,41 +0,0 @@ -import datetime -from workos.resources.base import WorkOSBaseResource - - -class MockSession(WorkOSBaseResource): - def __init__(self, id): - self.id = id - self.token = "session_token_123abc" - self.authorized_organizations = [ - { - "organization": { - "id": "org_01E4ZCR3C56J083X43JQXF3JK5", - "name": "Foo Corp", - } - } - ] - self.unauthorized_organizations = [ - { - "organization": { - "id": "org_01H7BA9A1YY5RGBTP1HYKVJPNC", - "name": "Bar Corp", - }, - "reasons": [ - { - "type": "authentication_method_required", - "allowed_authentication_methods": ["GoogleOauth"], - } - ], - } - ] - self.created_at = datetime.datetime.now() - self.updated_at = datetime.datetime.now() - - OBJECT_FIELDS = [ - "id", - "token", - "authorized_organizations", - "unauthorized_organizations", - "created_at", - "updated_at", - ] diff --git a/tests/utils/fixtures/mock_user.py b/tests/utils/fixtures/mock_user.py index a32f5f52..a9363f3e 100644 --- a/tests/utils/fixtures/mock_user.py +++ b/tests/utils/fixtures/mock_user.py @@ -1,28 +1,19 @@ import datetime -from workos.resources.base import WorkOSBaseResource +from workos.resources.user_management import User -class MockUser(WorkOSBaseResource): + +class MockUser(User): def __init__(self, id): now = datetime.datetime.now().isoformat() - self.object = "user" - self.id = id - self.email = "marcelina@foo-corp.com" - self.first_name = "Marcelina" - self.last_name = "Hoeger" - self.email_verified = False - self.profile_picture_url = "https://example.com/profile-picture.jpg" - self.created_at = now - self.updated_at = now - - OBJECT_FIELDS = [ - "object", - "id", - "email", - "first_name", - "last_name", - "profile_picture_url", - "email_verified", - "created_at", - "updated_at", - ] + super().__init__( + object="user", + id=id, + email="marcelina@foo-corp.com", + first_name="Marcelina", + last_name="Hoeger", + email_verified=False, + profile_picture_url="https://example.com/profile-picture.jpg", + created_at=now, + updated_at=now, + ) diff --git a/workos/resources/base.py b/workos/resources/base.py deleted file mode 100644 index b328c348..00000000 --- a/workos/resources/base.py +++ /dev/null @@ -1,39 +0,0 @@ -from typing import List - - -class WorkOSBaseResource(object): - """Representation of a WorkOS Resource as returned through the API. - - Attributes: - OBJECT_FIELDS (list): List of fields a Resource is comprised of. - """ - - OBJECT_FIELDS: List[str] = [] - - @classmethod - def construct_from_response(cls, response): - """Returns an instance of WorkOSBaseResource. - - Args: - response (dict): Resource data from a WorkOS API response - - Returns: - WorkOSBaseResource: Instance of a WorkOSBaseResource with OBJECT_FIELDS fields set - """ - obj = cls() - for field in cls.OBJECT_FIELDS: - setattr(obj, field, response.get(field)) - - return obj - - def to_dict(self): - """Returns a dict representation of the WorkOSBaseResource. - - Returns: - dict: A dict representation of the WorkOSBaseResource - """ - obj_dict = {} - for field in self.OBJECT_FIELDS: - obj_dict[field] = getattr(self, field, None) - - return obj_dict diff --git a/workos/resources/event_action.py b/workos/resources/event_action.py deleted file mode 100644 index 4658adb2..00000000 --- a/workos/resources/event_action.py +++ /dev/null @@ -1,10 +0,0 @@ -from workos.resources.base import WorkOSBaseResource - - -class WorkOSEventAction(WorkOSBaseResource): - """Representation of an Event Action as returned by WorkOS through the Audit Trail feature. - Attributes: - OBJECT_FIELDS (list): List of fields a WorkOSEventAction is comprised of. - """ - - OBJECT_FIELDS = ["id", "name"] diff --git a/workos/resources/list.py b/workos/resources/list.py index 1a5264d3..1e4f2363 100644 --- a/workos/resources/list.py +++ b/workos/resources/list.py @@ -14,7 +14,6 @@ cast, ) from typing_extensions import Required, TypedDict -from workos.resources.base import WorkOSBaseResource from workos.resources.directory_sync import ( Directory, DirectoryGroup, @@ -24,102 +23,11 @@ from workos.resources.mfa import AuthenticationFactor from workos.resources.organizations import Organization from pydantic import BaseModel, Field -from workos.resources.sso import Connection, ConnectionWithDomains +from workos.resources.sso import ConnectionWithDomains from workos.resources.user_management import Invitation, OrganizationMembership, User from workos.resources.workos_model import WorkOSModel -class WorkOSListResource(WorkOSBaseResource): - # TODO: THIS OLD RESOURCE GOES AWAY - """Representation of a WorkOS List Resource as returned through the API. - - Attributes: - OBJECT_FIELDS (list): List of fields a WorkOSListResource is comprised of. - """ - - OBJECT_FIELDS = ["data", "list_metadata", "metadata"] - - @classmethod - def construct_from_response(cls, response): - """Returns an instance of WorkOSListResource. - - Args: - response (dict): Resource data from a WorkOS API response - - Returns: - WorkOSListResource: Instance of a WorkOSListResource with OBJECT_FIELDS fields set - """ - obj = cls() - for field in cls.OBJECT_FIELDS: - setattr(obj, field, response.get(field)) - - return obj - - def to_dict(self): - """Returns a dict representation of the WorkOSListResource. - - Returns: - dict: A dict representation of the WorkOSListResource - """ - obj_dict = {} - for field in self.OBJECT_FIELDS: - obj_dict[field] = getattr(self, field, None) - - return obj_dict - - def auto_paging_iter(self): - """ - This function returns the entire list of items when there are more than 100 unless a limit has been specified. - """ - data_dict = self.to_dict() - data = data_dict["data"] - after = data_dict["list_metadata"]["after"] - before = data_dict["list_metadata"]["before"] - method = data_dict["metadata"]["method"] - order = data_dict["metadata"]["params"]["order"] - - keys_to_remove = ["after", "before"] - resource_specific_params = { - k: v - for k, v in self.to_dict()["metadata"]["params"].items() - if k not in keys_to_remove - } - - if "default_limit" not in resource_specific_params: - if len(data) == resource_specific_params["limit"]: - yield data - return - else: - del resource_specific_params["default_limit"] - - if before is None: - next_page_marker = after - string_direction = "after" - else: - order = None - next_page_marker = before - string_direction = "before" - - params = { - "after": after, - "before": before, - "order": order or "desc", - } - params.update(resource_specific_params) - - params = {k: v for k, v in params.items() if v is not None} - - while next_page_marker is not None: - response = method(self, **params) - if type(response) != dict: - response = response.to_dict() - for i in response["data"]: - data.append(i) - next_page_marker = response["list_metadata"][string_direction] - yield data - data = [] - - ListableResource = TypeVar( # add all possible generics of List Resource "ListableResource", diff --git a/workos/user_management.py b/workos/user_management.py index c3e179f4..d795673e 100644 --- a/workos/user_management.py +++ b/workos/user_management.py @@ -6,7 +6,6 @@ ListMetadata, ListPage, SyncOrAsyncListResource, - WorkOSListResource, WorkOsListResource, ) from workos.resources.mfa import ( @@ -378,7 +377,7 @@ def send_invitation( def revoke_invitation(self, invitation_id) -> Invitation: ... -class UserManagement(UserManagementModule, WorkOSListResource): +class UserManagement(UserManagementModule): """Offers methods for using the WorkOS User Management API.""" _http_client: SyncHTTPClient From 0294d3a9dd710bd5de1bb718ff9e11a561e94dc5 Mon Sep 17 00:00:00 2001 From: mattgd Date: Tue, 30 Jul 2024 16:46:21 -0400 Subject: [PATCH 18/18] Delete old files. --- .../mock_directory_activated_payload.py | 30 -------------- workos/resources/base.py | 39 ------------------- 2 files changed, 69 deletions(-) delete mode 100644 tests/utils/fixtures/mock_directory_activated_payload.py delete mode 100644 workos/resources/base.py diff --git a/tests/utils/fixtures/mock_directory_activated_payload.py b/tests/utils/fixtures/mock_directory_activated_payload.py deleted file mode 100644 index b4a0eeb8..00000000 --- a/tests/utils/fixtures/mock_directory_activated_payload.py +++ /dev/null @@ -1,30 +0,0 @@ -import datetime -from workos.resources.base import WorkOSBaseResource - - -class MockDirectoryActivatedPayload(WorkOSBaseResource): - def __init__(self, id): - now = datetime.datetime.now().isoformat() - self.object = "directory" - self.id = id - self.organization_id = "organization_id" - self.external_key = "ext_123" - self.domains = [] - self.name = "Some fake name" - self.state = "active" - self.type = "gsuite directory" - self.created_at = now - self.updated_at = now - - OBJECT_FIELDS = [ - "object", - "id", - "name", - "external_key", - "domains", - "organization_id", - "state", - "type", - "created_at", - "updated_at", - ] diff --git a/workos/resources/base.py b/workos/resources/base.py deleted file mode 100644 index b328c348..00000000 --- a/workos/resources/base.py +++ /dev/null @@ -1,39 +0,0 @@ -from typing import List - - -class WorkOSBaseResource(object): - """Representation of a WorkOS Resource as returned through the API. - - Attributes: - OBJECT_FIELDS (list): List of fields a Resource is comprised of. - """ - - OBJECT_FIELDS: List[str] = [] - - @classmethod - def construct_from_response(cls, response): - """Returns an instance of WorkOSBaseResource. - - Args: - response (dict): Resource data from a WorkOS API response - - Returns: - WorkOSBaseResource: Instance of a WorkOSBaseResource with OBJECT_FIELDS fields set - """ - obj = cls() - for field in cls.OBJECT_FIELDS: - setattr(obj, field, response.get(field)) - - return obj - - def to_dict(self): - """Returns a dict representation of the WorkOSBaseResource. - - Returns: - dict: A dict representation of the WorkOSBaseResource - """ - obj_dict = {} - for field in self.OBJECT_FIELDS: - obj_dict[field] = getattr(self, field, None) - - return obj_dict