From 722c39c46859ab6757f80ca9c63ed34e69c32923 Mon Sep 17 00:00:00 2001 From: mattgd Date: Tue, 26 Nov 2024 10:00:14 -0500 Subject: [PATCH 1/3] Support specific ConflictException. --- tests/test_sync_http_client.py | 18 ++++++++++++++++++ workos/exceptions.py | 4 ++++ workos/utils/_base_http_client.py | 3 +++ 3 files changed, 25 insertions(+) diff --git a/tests/test_sync_http_client.py b/tests/test_sync_http_client.py index 1aef7ed0..56c328c2 100644 --- a/tests/test_sync_http_client.py +++ b/tests/test_sync_http_client.py @@ -9,6 +9,7 @@ AuthorizationException, BadRequestException, BaseRequestException, + ConflictException, ServerException, ) from workos.utils.http_client import SyncHTTPClient @@ -251,6 +252,23 @@ def test_request_bad_body_raises_expected_exception_with_request_data(self): # This'll fail for sure here but... just using the nice error that'd come up assert ex.__class__ == ServerException + def test_conflict_exception(self): + request_id = "request-123" + + self.http_client._client.request = MagicMock( + return_value=httpx.Response( + status_code=409, + headers={"X-Request-ID": request_id}, + ), + ) + + try: + self.http_client.request("bad_place") + except ConflictException as ex: + assert str(ex) == "(message=No message, request_id=request-123)" + except Exception as ex: + assert ex.__class__ == ConflictException + 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) diff --git a/workos/exceptions.py b/workos/exceptions.py index acd1901c..9aee53b2 100644 --- a/workos/exceptions.py +++ b/workos/exceptions.py @@ -53,6 +53,10 @@ class BadRequestException(BaseRequestException): pass +class ConflictException(BaseRequestException): + pass + + class NotFoundException(BaseRequestException): pass diff --git a/workos/utils/_base_http_client.py b/workos/utils/_base_http_client.py index 30c1bca4..a9ab0c55 100644 --- a/workos/utils/_base_http_client.py +++ b/workos/utils/_base_http_client.py @@ -16,6 +16,7 @@ from httpx._types import QueryParamTypes from workos.exceptions import ( + ConflictException, ServerException, AuthenticationException, AuthorizationException, @@ -101,6 +102,8 @@ def _maybe_raise_error_by_status_code( raise AuthorizationException(response, response_json) elif status_code == 404: raise NotFoundException(response, response_json) + elif status_code == 409: + raise ConflictException(response, response_json) raise BadRequestException(response, response_json) elif status_code >= 500 and status_code < 600: From 59c4936e2e03d80c496f72efd026ac4aa6e2e867 Mon Sep 17 00:00:00 2001 From: mattgd Date: Wed, 27 Nov 2024 10:00:22 -0500 Subject: [PATCH 2/3] Update exception tests. --- tests/test_async_http_client.py | 29 ++++++++++++++++++++++------- tests/test_sync_http_client.py | 6 ------ 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/tests/test_async_http_client.py b/tests/test_async_http_client.py index 4d9c5425..d953ad29 100644 --- a/tests/test_async_http_client.py +++ b/tests/test_async_http_client.py @@ -5,7 +5,12 @@ 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.exceptions import ( + BadRequestException, + BaseRequestException, + ConflictException, + ServerException, +) from workos.utils.http_client import AsyncHTTPClient @@ -186,8 +191,6 @@ async def test_request_exceptions_include_expected_request_data( 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): @@ -210,7 +213,6 @@ async def test_bad_request_exceptions_include_expected_request_data(self): 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): @@ -228,7 +230,6 @@ async def test_bad_request_exceptions_exclude_expected_request_data(self): await self.http_client.request("bad_place") except BadRequestException as ex: assert str(ex) == "(message=No message, request_id=request-123, foo=bar)" - except Exception as ex: assert ex.__class__ == BadRequestException async def test_request_bad_body_raises_expected_exception_with_request_data(self): @@ -247,10 +248,24 @@ async def test_request_bad_body_raises_expected_exception_with_request_data(self 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_conflict_exception(self): + request_id = "request-123" + + self.http_client._client.request = AsyncMock( + return_value=httpx.Response( + status_code=409, + headers={"X-Request-ID": request_id}, + ), + ) + + try: + await self.http_client.request("bad_place") + except ConflictException as ex: + assert str(ex) == "(message=No message, request_id=request-123)" + assert ex.__class__ == ConflictException + async def test_request_includes_base_headers( self, capture_and_mock_http_client_request ): diff --git a/tests/test_sync_http_client.py b/tests/test_sync_http_client.py index 56c328c2..2a0f571b 100644 --- a/tests/test_sync_http_client.py +++ b/tests/test_sync_http_client.py @@ -201,8 +201,6 @@ def test_request_exceptions_include_expected_request_data( 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_request_data(self): @@ -229,7 +227,6 @@ def test_bad_request_exceptions_include_request_data(self): str(ex) == "(message=No message, request_id=request-123, error=example_error, error_description=Example error description, foo=bar)" ) - except Exception as ex: assert ex.__class__ == BadRequestException def test_request_bad_body_raises_expected_exception_with_request_data(self): @@ -248,8 +245,6 @@ def test_request_bad_body_raises_expected_exception_with_request_data(self): 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_conflict_exception(self): @@ -266,7 +261,6 @@ def test_conflict_exception(self): self.http_client.request("bad_place") except ConflictException as ex: assert str(ex) == "(message=No message, request_id=request-123)" - except Exception as ex: assert ex.__class__ == ConflictException def test_request_includes_base_headers(self, capture_and_mock_http_client_request): From a9a4a4455d7bee313ad105c6004fde9c954e5ae0 Mon Sep 17 00:00:00 2001 From: mattgd Date: Thu, 12 Dec 2024 15:19:53 -0500 Subject: [PATCH 3/3] Add GET /organization/:orgId/roles support. --- tests/test_organizations.py | 33 +++++++++++++++++++++++++++++++ tests/utils/fixtures/mock_role.py | 18 +++++++++++++++++ workos/organizations.py | 17 ++++++++++++++++ workos/types/roles/role.py | 21 +++++++++++++++++++- 4 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 tests/utils/fixtures/mock_role.py diff --git a/tests/test_organizations.py b/tests/test_organizations.py index fef868f7..60cb60a6 100644 --- a/tests/test_organizations.py +++ b/tests/test_organizations.py @@ -3,6 +3,7 @@ import pytest from tests.types.test_auto_pagination_function import TestAutoPaginationFunction from tests.utils.fixtures.mock_organization import MockOrganization +from tests.utils.fixtures.mock_role import MockRole from tests.utils.list_resource import list_response_of from tests.utils.syncify import syncify from workos.organizations import AsyncOrganizations, Organizations @@ -67,6 +68,13 @@ def mock_organizations_multiple_data_pages(self): ] return list_response_of(data=organizations_list) + @pytest.fixture + def mock_organization_roles(self): + return { + "data": [MockRole(id=str(i)).dict() for i in range(10)], + "object": "list", + } + def test_list_organizations( self, mock_organizations, capture_and_mock_http_client_request ): @@ -227,3 +235,28 @@ def test_list_organizations_auto_pagination_for_multiple_pages( list_function=self.organizations.list_organizations, expected_all_page_data=mock_organizations_multiple_data_pages["data"], ) + + def test_list_organization_roles( + self, mock_organization_roles, capture_and_mock_http_client_request + ): + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock_organization_roles, 200 + ) + + organization_roles_response = syncify( + self.organizations.list_organization_roles( + organization_id="org_01EHT88Z8J8795GZNQ4ZP1J81T" + ) + ) + + def to_dict(x): + return x.dict() + + assert request_kwargs["method"] == "get" + assert request_kwargs["url"].endswith( + "/organizations/org_01EHT88Z8J8795GZNQ4ZP1J81T/roles" + ) + assert ( + list(map(to_dict, organization_roles_response.data)) + == mock_organization_roles["data"] + ) diff --git a/tests/utils/fixtures/mock_role.py b/tests/utils/fixtures/mock_role.py new file mode 100644 index 00000000..63ae087c --- /dev/null +++ b/tests/utils/fixtures/mock_role.py @@ -0,0 +1,18 @@ +import datetime + +from workos.types.roles.role import OrganizationRole + + +class MockRole(OrganizationRole): + def __init__(self, id): + now = datetime.datetime.now().isoformat() + super().__init__( + object="role", + id=id, + name="Member", + slug="member", + description="The default member role", + type="EnvironmentRole", + created_at=now, + updated_at=now, + ) diff --git a/workos/organizations.py b/workos/organizations.py index 61ec9218..237a4a4a 100644 --- a/workos/organizations.py +++ b/workos/organizations.py @@ -2,6 +2,7 @@ from workos.types.organizations.domain_data_input import DomainDataInput from workos.types.organizations.list_filters import OrganizationListFilters +from workos.types.roles.role import OrganizationRole, RolesList from workos.typing.sync_or_async import SyncOrAsync from workos.utils.http_client import AsyncHTTPClient, SyncHTTPClient from workos.utils.pagination_order import PaginationOrder @@ -223,6 +224,14 @@ def delete_organization(self, organization_id: str) -> None: method=REQUEST_METHOD_DELETE, ) + def list_organization_roles(self, organization_id: str) -> RolesList: + response = self._http_client.request( + f"organizations/{organization_id}/roles", + method=REQUEST_METHOD_GET, + ) + + return RolesList.model_validate(response) + class AsyncOrganizations(OrganizationsModule): @@ -324,3 +333,11 @@ async def delete_organization(self, organization_id: str) -> None: f"organizations/{organization_id}", method=REQUEST_METHOD_DELETE, ) + + async def list_organization_roles(self, organization_id: str) -> RolesList: + response = await self._http_client.request( + f"organizations/{organization_id}/roles", + method=REQUEST_METHOD_GET, + ) + + return RolesList.model_validate(response) diff --git a/workos/types/roles/role.py b/workos/types/roles/role.py index 8b0886b8..cd271164 100644 --- a/workos/types/roles/role.py +++ b/workos/types/roles/role.py @@ -1,8 +1,27 @@ from typing import Literal, Optional, Sequence from workos.types.workos_model import WorkOSModel +RoleType = Literal["EnvironmentRole", "OrganizationRole"] -class Role(WorkOSModel): + +class RoleCommon(WorkOSModel): object: Literal["role"] slug: str + + +class Role(RoleCommon): permissions: Optional[Sequence[str]] = None + + +class OrganizationRole(RoleCommon): + id: str + name: str + description: Optional[str] = None + type: RoleType + created_at: str + updated_at: str + + +class RolesList(WorkOSModel): + object: Literal["list"] + data: Sequence[OrganizationRole]