diff --git a/README.md b/README.md index a2121e2..31ff537 100644 --- a/README.md +++ b/README.md @@ -290,7 +290,7 @@ print(resent["message_id"]) # the new message; it links back through resent_fro | `routes` | `list(project_id)`, `iterate(project_id)`, `create(project_id, …)`, `retrieve`, `update`, `delete`, `verify_inbound_domain` | | `stats` | `retrieve` | | `suppressions` | `list`, `iterate`, `create`, `delete` | -| `team` | `retrieve`, `update`, `usage`, `roles` | +| `team` | `retrieve`, `update`, `usage`, `roles`, `retrieve_role`, `create_role`, `update_role`, `delete_role`, `duplicate_role` | | `team.members` | `list`, `iterate`, `retrieve`, `update_assignment` | | `team.invites` | `list`, `iterate`, `retrieve`, `create`, `update`, `revoke` | | `webhooks` | `list`, `iterate`, `create`, `retrieve`, `update`, `delete`, `test`, `regenerate_secret` | @@ -308,6 +308,17 @@ invite = lettermint.team.invites.create({ lettermint.team.invites.revoke(invite["id"]) ``` +`team.create_role()` creates a custom role; it needs a plan with custom roles. The permissions must include the permissions they depend on, and role names are unique. `team.update_role()` replaces a custom role's name, description and permissions, and `team.duplicate_role()` copies a role under a new name (without a `description`, the copy keeps the source role's). Built-in roles cannot be changed or deleted, and the Owner role cannot be duplicated. While a role is still assigned to members, invites or SCIM groups, `team.delete_role()` needs a `replacement_role_id` that receives those assignments: + +```python +role = lettermint.team.create_role({ + "name": "Support", + "description": "Handles customer questions", + "permissions": ["support:manage"], +}) +lettermint.team.delete_role(role["id"], {"replacement_role_id": "member-role-id"}) +``` + `webhooks.deliveries.retry()` sends a delivery's original payload to the webhook again now. The retry counts as a delivery attempt; if it fails with a retryable error, the automatic retries continue. The API answers `409` (`ConflictError`) when the webhook is disabled, the delivery cannot be retried or a retry is already in progress. ### Query parameters and pagination diff --git a/src/lettermint/_async/resources.py b/src/lettermint/_async/resources.py index 077cc1b..8443731 100644 --- a/src/lettermint/_async/resources.py +++ b/src/lettermint/_async/resources.py @@ -14,11 +14,14 @@ from .._generated.operations import OPERATIONS from .._generated.types import ( CreateTeamInviteData, + CreateTeamRoleData, DeleteSuppressionResponse, + DeleteTeamRoleQuery, DnsVerificationSuccessResponse, DomainData, DomainListData, DomainMutationResponse, + DuplicateTeamRoleData, GetDomainQuery, GetProjectQuery, GetReportForwardingResponse, @@ -78,6 +81,7 @@ TeamInviteData, TeamMemberData, TeamMutationResponse, + TeamRoleData, TeamRoleListResponse, TeamUsageDetailData, TestWebhookResponse, @@ -88,6 +92,7 @@ UpdateTeamData, UpdateTeamInviteData, UpdateTeamMemberAssignmentData, + UpdateTeamRoleData, UpdateWebhookData, VerifyReportForwardingRequest, VerifyReportForwardingResponse, @@ -924,6 +929,89 @@ async def roles(self, *, timeout: float | None = None) -> TeamRoleListResponse: await self._request("GET /team/roles", "team.roles", timeout=timeout), ) + async def retrieve_role(self, role_id: str, *, timeout: float | None = None) -> TeamRoleData: + return cast( + TeamRoleData, + await self._request( + "GET /team/roles/{roleId}", + "team.retrieve_role", + path={"roleId": role_id}, + timeout=timeout, + ), + ) + + async def create_role( + self, body: CreateTeamRoleData, *, timeout: float | None = None + ) -> TeamRoleData: + """Creates a custom role. + + Needs a plan with custom roles. The permissions must include the permissions they depend + on, and role names are unique within the team. + """ + return cast( + TeamRoleData, + await self._request("POST /team/roles", "team.create_role", body=body, timeout=timeout), + ) + + async def update_role( + self, role_id: str, body: UpdateTeamRoleData, *, timeout: float | None = None + ) -> TeamRoleData: + """Replaces the name, description and permissions of a custom role. + + Built-in roles cannot be changed. + """ + return cast( + TeamRoleData, + await self._request( + "PUT /team/roles/{roleId}", + "team.update_role", + path={"roleId": role_id}, + body=body, + timeout=timeout, + ), + ) + + async def delete_role( + self, + role_id: str, + query: DeleteTeamRoleQuery | None = None, + *, + timeout: float | None = None, + ) -> MessageResponse: + """Deletes a custom role. Built-in roles cannot be deleted. + + While the role is still assigned to members, invites or SCIM groups, pass + ``{"replacement_role_id": ...}``: the role that receives those assignments. + """ + return cast( + MessageResponse, + await self._request( + "DELETE /team/roles/{roleId}", + "team.delete_role", + path={"roleId": role_id}, + query=query, + timeout=timeout, + ), + ) + + async def duplicate_role( + self, role_id: str, body: DuplicateTeamRoleData, *, timeout: float | None = None + ) -> TeamRoleData: + """Copies a role under a new name. The Owner role cannot be duplicated. + + Without a ``description``, the copy keeps the description of the source role. + """ + return cast( + TeamRoleData, + await self._request( + "POST /team/roles/{roleId}/duplicate", + "team.duplicate_role", + path={"roleId": role_id}, + body=body, + timeout=timeout, + ), + ) + class AsyncWebhookDeliveries(AsyncResource): """Delivery attempts of a webhook. Team token.""" diff --git a/src/lettermint/_sync/resources.py b/src/lettermint/_sync/resources.py index 4cf256c..8013243 100644 --- a/src/lettermint/_sync/resources.py +++ b/src/lettermint/_sync/resources.py @@ -15,11 +15,14 @@ from .._generated.operations import OPERATIONS from .._generated.types import ( CreateTeamInviteData, + CreateTeamRoleData, DeleteSuppressionResponse, + DeleteTeamRoleQuery, DnsVerificationSuccessResponse, DomainData, DomainListData, DomainMutationResponse, + DuplicateTeamRoleData, GetDomainQuery, GetProjectQuery, GetReportForwardingResponse, @@ -79,6 +82,7 @@ TeamInviteData, TeamMemberData, TeamMutationResponse, + TeamRoleData, TeamRoleListResponse, TeamUsageDetailData, TestWebhookResponse, @@ -89,6 +93,7 @@ UpdateTeamData, UpdateTeamInviteData, UpdateTeamMemberAssignmentData, + UpdateTeamRoleData, UpdateWebhookData, VerifyReportForwardingRequest, VerifyReportForwardingResponse, @@ -925,6 +930,89 @@ def roles(self, *, timeout: float | None = None) -> TeamRoleListResponse: self._request("GET /team/roles", "team.roles", timeout=timeout), ) + def retrieve_role(self, role_id: str, *, timeout: float | None = None) -> TeamRoleData: + return cast( + TeamRoleData, + self._request( + "GET /team/roles/{roleId}", + "team.retrieve_role", + path={"roleId": role_id}, + timeout=timeout, + ), + ) + + def create_role( + self, body: CreateTeamRoleData, *, timeout: float | None = None + ) -> TeamRoleData: + """Creates a custom role. + + Needs a plan with custom roles. The permissions must include the permissions they depend + on, and role names are unique within the team. + """ + return cast( + TeamRoleData, + self._request("POST /team/roles", "team.create_role", body=body, timeout=timeout), + ) + + def update_role( + self, role_id: str, body: UpdateTeamRoleData, *, timeout: float | None = None + ) -> TeamRoleData: + """Replaces the name, description and permissions of a custom role. + + Built-in roles cannot be changed. + """ + return cast( + TeamRoleData, + self._request( + "PUT /team/roles/{roleId}", + "team.update_role", + path={"roleId": role_id}, + body=body, + timeout=timeout, + ), + ) + + def delete_role( + self, + role_id: str, + query: DeleteTeamRoleQuery | None = None, + *, + timeout: float | None = None, + ) -> MessageResponse: + """Deletes a custom role. Built-in roles cannot be deleted. + + While the role is still assigned to members, invites or SCIM groups, pass + ``{"replacement_role_id": ...}``: the role that receives those assignments. + """ + return cast( + MessageResponse, + self._request( + "DELETE /team/roles/{roleId}", + "team.delete_role", + path={"roleId": role_id}, + query=query, + timeout=timeout, + ), + ) + + def duplicate_role( + self, role_id: str, body: DuplicateTeamRoleData, *, timeout: float | None = None + ) -> TeamRoleData: + """Copies a role under a new name. The Owner role cannot be duplicated. + + Without a ``description``, the copy keeps the description of the source role. + """ + return cast( + TeamRoleData, + self._request( + "POST /team/roles/{roleId}/duplicate", + "team.duplicate_role", + path={"roleId": role_id}, + body=body, + timeout=timeout, + ), + ) + class WebhookDeliveries(Resource): """Delivery attempts of a webhook. Team token.""" diff --git a/tests/test_surface.py b/tests/test_surface.py index 1477b0e..a1c9cf0 100644 --- a/tests/test_surface.py +++ b/tests/test_surface.py @@ -112,6 +112,23 @@ ("team.update", ({"name": "Acme"},), "PUT /team"), ("team.usage", (), "GET /team/usage"), ("team.roles", (), "GET /team/roles"), + ("team.retrieve_role", ("r1",), "GET /team/roles/{roleId}"), + ( + "team.create_role", + ({"name": "Support", "permissions": ["support:manage"]},), + "POST /team/roles", + ), + ( + "team.update_role", + ("r1", {"name": "Support", "permissions": ["support:manage"]}), + "PUT /team/roles/{roleId}", + ), + ("team.delete_role", ("r1",), "DELETE /team/roles/{roleId}"), + ( + "team.duplicate_role", + ("r1", {"name": "Support copy"}), + "POST /team/roles/{roleId}/duplicate", + ), ("team.members.list", (), "GET /team/members"), ("team.members.iterate", (), "GET /team/members"), ("team.members.retrieve", ("u1",), "GET /team/members/{userId}"), @@ -160,13 +177,8 @@ ), ] -# Operations without a method yet: added by the next PRs in this stack. +# Operations without a method yet: added by the next PR in this stack. PENDING = { - "GET /team/roles/{roleId}", - "POST /team/roles", - "PUT /team/roles/{roleId}", - "DELETE /team/roles/{roleId}", - "POST /team/roles/{roleId}/duplicate", "DELETE /team/members/{userId}", } @@ -208,7 +220,17 @@ ], "stats": ["retrieve"], "suppressions": ["create", "delete", "iterate", "list"], - "team": ["retrieve", "roles", "update", "usage"], + "team": [ + "create_role", + "delete_role", + "duplicate_role", + "retrieve", + "retrieve_role", + "roles", + "update", + "update_role", + "usage", + ], "team.invites": ["create", "iterate", "list", "retrieve", "revoke", "update"], "team.members": ["iterate", "list", "retrieve", "update_assignment"], "webhooks": [ diff --git a/tests/test_team.py b/tests/test_team.py index 30b92e4..5024090 100644 --- a/tests/test_team.py +++ b/tests/test_team.py @@ -1,4 +1,4 @@ -"""``team.invites``: the requests, the token, the bodies and the errors.""" +"""``team.invites`` and the team role methods: the requests, the token, the bodies and the errors.""" from __future__ import annotations @@ -16,9 +16,11 @@ ) from lettermint.types import ( CreateTeamInviteData, + CreateTeamRoleData, ListTeamInvitesResponse, MessageResponse, TeamInviteData, + TeamRoleData, UpdateTeamInviteData, ) @@ -188,3 +190,157 @@ async def test_async_invites(api: MockAPI, aclient: AsyncLettermint) -> None: api.json(200, {"message": "Invite revoked."}) assert await aclient.team.invites.revoke("i1") == {"message": "Invite revoked."} assert_team_request(api, "DELETE", "/team/invites/i1") + + +ROLE: TeamRoleData = { + "id": "r1", + "name": "Support", + "description": "Handles customer questions", + "system_key": None, + "permissions": ["support:manage"], + "assignable": True, +} + +NEW_ROLE: CreateTeamRoleData = { + "name": "Support", + "description": "Handles customer questions", + "permissions": ["support:manage"], +} + + +@pytest.mark.parametrize("description", ["Handles customer questions", None]) +def test_retrieve_role_parses_the_description( + api: MockAPI, client: Lettermint, description: str | None +) -> None: + api.json(200, {**ROLE, "description": description}) + role: TeamRoleData = client.team.retrieve_role("r1") + assert role["description"] == description + assert role == {**ROLE, "description": description} + assert_team_request(api, "GET", "/team/roles/r1") + assert api.last.content == b"" + + +def test_a_role_without_a_description_still_parses(api: MockAPI, client: Lettermint) -> None: + legacy = {key: value for key, value in ROLE.items() if key != "description"} + api.json(200, {"data": [legacy]}) + assert "description" not in client.team.roles()["data"][0] + + +def test_create_role_sends_the_body(api: MockAPI, client: Lettermint) -> None: + api.json(201, ROLE) + role: TeamRoleData = client.team.create_role(NEW_ROLE) + assert role == ROLE + assert_team_request(api, "POST", "/team/roles") + assert api.last.headers["content-type"] == "application/json" + assert api.body() == NEW_ROLE + client.team.create_role({"name": "Support", "permissions": ["support:manage"]}) + assert api.body() == {"name": "Support", "permissions": ["support:manage"]} + client.team.create_role({**NEW_ROLE, "description": None}) + assert api.body() == {**NEW_ROLE, "description": None} + + +def test_update_role_replaces_it(api: MockAPI, client: Lettermint) -> None: + api.json(200, ROLE) + role: TeamRoleData = client.team.update_role("r1", NEW_ROLE) + assert role == ROLE + assert_team_request(api, "PUT", "/team/roles/r1") + assert api.body() == NEW_ROLE + + +def test_delete_role_without_a_replacement_sends_no_query(api: MockAPI, client: Lettermint) -> None: + api.json(200, {"message": "Role deleted."}) + result: MessageResponse = client.team.delete_role("r1") + assert result == {"message": "Role deleted."} + assert_team_request(api, "DELETE", "/team/roles/r1") + assert api.last.url.query == b"" + assert api.last.content == b"" + client.team.delete_role("r1", {}) + assert api.last.url.query == b"" + client.team.delete_role("r1", {"replacement_role_id": None}) + assert api.last.url.query == b"" + + +def test_delete_role_with_a_replacement(api: MockAPI, client: Lettermint) -> None: + api.json(200, {"message": "Role deleted."}) + client.team.delete_role("r1", {"replacement_role_id": "r2"}) + assert_team_request(api, "DELETE", "/team/roles/r1?replacement_role_id=r2") + assert api.last.url.params["replacement_role_id"] == "r2" + + +@pytest.mark.parametrize( + "body", [{"name": "Support copy"}, {"name": "Support copy", "description": None}] +) +def test_duplicate_role(api: MockAPI, client: Lettermint, body: Any) -> None: + api.json(201, {**ROLE, "id": "r2", "name": "Support copy"}) + role: TeamRoleData = client.team.duplicate_role("r1", body) + assert (role["id"], role["name"]) == ("r2", "Support copy") + assert_team_request(api, "POST", "/team/roles/r1/duplicate") + assert api.body() == body + + +@pytest.mark.parametrize( + ("call", "label"), + [ + (lambda c, i: c.team.retrieve_role(i), "retrieve_role"), + (lambda c, i: c.team.update_role(i, NEW_ROLE), "update_role"), + (lambda c, i: c.team.delete_role(i), "delete_role"), + (lambda c, i: c.team.duplicate_role(i, {"name": "Copy"}), "duplicate_role"), + ], +) +def test_role_ids_are_checked_and_encoded( + api: MockAPI, client: Lettermint, call: Any, label: str +) -> None: + api.json(200, ROLE) + for invalid in ("", ".."): + with pytest.raises(LettermintConfigError, match=rf"^team\.{label}: roleId"): + call(client, invalid) + assert api.requests == [] + call(client, "a/b") + assert api.last.url.raw_path.startswith(b"/v1/team/roles/a%2Fb") + + +def test_role_methods_need_the_team_token(api: MockAPI) -> None: + sending_only = Lettermint(SENDING_TOKEN, base_url=BASE_URL, http_client=api.sync_client()) + with pytest.raises(LettermintConfigError, match=r"^team\.create_role needs team_token"): + sending_only.team.create_role(NEW_ROLE) + with pytest.raises(LettermintConfigError, match=r"^team\.delete_role needs team_token"): + sending_only.team.delete_role("r1") + assert api.requests == [] + + +def test_a_role_conflict_is_a_conflict_error(api: MockAPI, client: Lettermint) -> None: + body = {"error": {"code": "built_in_role_immutable", "message": "Built-in roles are fixed."}} + api.json(409, body) + with pytest.raises(ConflictError) as caught: + client.team.update_role("r1", NEW_ROLE) + assert (caught.value.status, caught.value.code, caught.value.body) == ( + 409, + "built_in_role_immutable", + body, + ) + + +def test_a_role_still_in_use_is_a_validation_error(api: MockAPI, client: Lettermint) -> None: + body = { + "message": "A replacement role is required.", + "errors": {"replacement_role_id": ["A replacement role is required."]}, + } + api.json(422, body) + with pytest.raises(ValidationError) as caught: + client.team.delete_role("r1") + assert caught.value.errors == body["errors"] + + +@pytest.mark.anyio +async def test_async_roles(api: MockAPI, aclient: AsyncLettermint) -> None: + api.json(201, ROLE) + role: TeamRoleData = await aclient.team.create_role(NEW_ROLE) + assert role == ROLE + assert_team_request(api, "POST", "/team/roles") + assert api.body() == NEW_ROLE + api.json(200, {"message": "Role deleted."}) + await aclient.team.delete_role("r1", {"replacement_role_id": "r2"}) + assert_team_request(api, "DELETE", "/team/roles/r1?replacement_role_id=r2") + api.json(201, ROLE) + await aclient.team.duplicate_role("r1", {"name": "Copy"}) + assert_team_request(api, "POST", "/team/roles/r1/duplicate")