diff --git a/examples/async/oauth_grants_async.py b/examples/async/oauth_grants_async.py new file mode 100644 index 0000000..80c5bbd --- /dev/null +++ b/examples/async/oauth_grants_async.py @@ -0,0 +1,30 @@ +import asyncio +import os + +import resend + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + + +async def main() -> None: + grants: resend.OAuthGrants.ListResponse = await resend.OAuthGrants.list_async() + print(f"Found {len(grants['data'])} OAuth grants") + for grant in grants["data"]: + print(grant["id"]) + print(grant["client_id"]) + print(grant["scopes"]) + print(grant["created_at"]) + print(grant["client"]["name"]) + + if grants["data"]: + revoked = await resend.OAuthGrants.revoke_async( + oauth_grant_id=grants["data"][0]["id"] + ) + print(f"Revoked OAuth grant: {revoked['id']}") + print(f"Revoked at: {revoked['revoked_at']}") + print(f"Revoked reason: {revoked['revoked_reason']}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/oauth_grants.py b/examples/oauth_grants.py new file mode 100644 index 0000000..025ecd1 --- /dev/null +++ b/examples/oauth_grants.py @@ -0,0 +1,40 @@ +import os + +import resend + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + +grants: resend.OAuthGrants.ListResponse = resend.OAuthGrants.list() +print(f"Found {len(grants['data'])} OAuth grants") +print(f"Has more grants: {grants['has_more']}") +if not grants["data"]: + print("No OAuth grants found") +for grant in grants["data"]: + print(grant["id"]) + print(grant["client_id"]) + print(grant["scopes"]) + print(grant["created_at"]) + print(grant["client"]["name"]) + +print("\n--- Using pagination parameters ---") +if grants["data"]: + paginated_params: resend.OAuthGrants.ListParams = { + "limit": 5, + "after": grants["data"][0]["id"], + } + paginated_grants: resend.OAuthGrants.ListResponse = resend.OAuthGrants.list( + params=paginated_params + ) + print(f"Retrieved {len(paginated_grants['data'])} grants with pagination") + print(f"Has more grants: {paginated_grants['has_more']}") +else: + print("No OAuth grants available for pagination example") + +if grants["data"]: + revoked: resend.OAuthGrants.RevokeOAuthGrantResponse = resend.OAuthGrants.revoke( + oauth_grant_id=grants["data"][0]["id"] + ) + print(f"Revoked OAuth grant: {revoked['id']}") + print(f"Revoked at: {revoked['revoked_at']}") + print(f"Revoked reason: {revoked['revoked_reason']}") diff --git a/resend/__init__.py b/resend/__init__.py index 7b4b4e8..2a157d9 100644 --- a/resend/__init__.py +++ b/resend/__init__.py @@ -47,6 +47,8 @@ from .http_client_requests import RequestsClient from .logs._log import Log from .logs._logs import Logs +from .oauth_grants._oauth_grant import OAuthGrant, OAuthGrantClient +from .oauth_grants._oauth_grants import OAuthGrants from .request import Request from .segments._segment import Segment from .segments._segments import Segments @@ -94,6 +96,7 @@ "Webhooks", "Topics", "Logs", + "OAuthGrants", # Types "Audience", "Automation", @@ -139,6 +142,8 @@ "WebhookStatus", "VerifyWebhookOptions", "Topic", + "OAuthGrant", + "OAuthGrantClient", "BatchValidationError", "ReceivedEmail", "EmailAttachment", diff --git a/resend/oauth_grants/__init__.py b/resend/oauth_grants/__init__.py new file mode 100644 index 0000000..c8def72 --- /dev/null +++ b/resend/oauth_grants/__init__.py @@ -0,0 +1,4 @@ +from resend.oauth_grants._oauth_grant import OAuthGrant, OAuthGrantClient +from resend.oauth_grants._oauth_grants import OAuthGrants + +__all__ = ["OAuthGrant", "OAuthGrantClient", "OAuthGrants"] diff --git a/resend/oauth_grants/_oauth_grant.py b/resend/oauth_grants/_oauth_grant.py new file mode 100644 index 0000000..60c7a74 --- /dev/null +++ b/resend/oauth_grants/_oauth_grant.py @@ -0,0 +1,45 @@ +from typing import List, Optional + +from typing_extensions import TypedDict + + +class OAuthGrantClient(TypedDict): + name: str + """ + The name of the OAuth client the grant was issued to + """ + logo_uri: Optional[str] + """ + The URL of the OAuth client's logo, or None if not set + """ + + +class OAuthGrant(TypedDict): + id: str + """ + The OAuth grant ID + """ + client_id: str + """ + The ID of the OAuth client the grant was issued to + """ + scopes: List[str] + """ + The scopes granted to the OAuth client + """ + created_at: str + """ + The date and time the grant was created + """ + revoked_at: Optional[str] + """ + The date and time the grant was revoked, or None if it is still active + """ + revoked_reason: Optional[str] + """ + The reason the grant was revoked, or None if it is still active + """ + client: OAuthGrantClient + """ + The OAuth client the grant was issued to + """ diff --git a/resend/oauth_grants/_oauth_grants.py b/resend/oauth_grants/_oauth_grants.py new file mode 100644 index 0000000..fb23241 --- /dev/null +++ b/resend/oauth_grants/_oauth_grants.py @@ -0,0 +1,165 @@ +from typing import Any, Dict, List, Optional, cast + +from typing_extensions import NotRequired, TypedDict + +from resend import request +from resend._base_response import BaseResponse +from resend.oauth_grants._oauth_grant import OAuthGrant +from resend.pagination_helper import PaginationHelper + +# Async imports (optional - only available with pip install resend[async]) +try: + from resend.async_request import AsyncRequest +except ImportError: + pass + + +class OAuthGrants: + + class ListResponse(BaseResponse): + """ + ListResponse type that wraps a list of OAuth grant objects with pagination metadata + + Attributes: + object (str): The object type, always "list" + data (List[OAuthGrant]): A list of OAuth grant objects + has_more (bool): Whether there are more results available + """ + + object: str + """ + The object type, always "list" + """ + data: List[OAuthGrant] + """ + A list of OAuth grant objects + """ + has_more: bool + """ + Whether there are more results available for pagination + """ + + class RevokeOAuthGrantResponse(BaseResponse): + """ + RevokeOAuthGrantResponse is the type that wraps the revoked OAuth grant response. + + Attributes: + object (str): The object type, always "oauth_grant" + id (str): The ID of the revoked OAuth grant + revoked_at (str): The date and time the grant was revoked + revoked_reason (str): The reason the grant was revoked + """ + + object: str + """ + The object type, always "oauth_grant" + """ + id: str + """ + The ID of the revoked OAuth grant + """ + revoked_at: str + """ + The date and time the grant was revoked + """ + revoked_reason: str + """ + The reason the grant was revoked + """ + + class ListParams(TypedDict): + limit: NotRequired[int] + """ + Number of OAuth grants to retrieve. Maximum is 100, and minimum is 1. + """ + after: NotRequired[str] + """ + The ID after which we'll retrieve more OAuth grants (for pagination). + This ID will not be included in the returned list. + Cannot be used with the before parameter. + """ + before: NotRequired[str] + """ + The ID before which we'll retrieve more OAuth grants (for pagination). + This ID will not be included in the returned list. + Cannot be used with the after parameter. + """ + + @classmethod + def list(cls, params: Optional[ListParams] = None) -> ListResponse: + """ + Retrieve a list of OAuth grants for the authenticated user. + see more: https://resend.com/docs/api-reference/oauth/list-grants + + Args: + params (Optional[ListParams]): Optional pagination parameters + - limit: Number of OAuth grants to retrieve (max 100, min 1) + - after: ID after which to retrieve more OAuth grants + - before: ID before which to retrieve more OAuth grants + + Returns: + ListResponse: A list of OAuth grant objects + """ + base_path = "/oauth/grants" + query_params = cast(Dict[Any, Any], params) if params else None + path = PaginationHelper.build_paginated_path(base_path, query_params) + resp = request.Request[OAuthGrants.ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + def revoke(cls, oauth_grant_id: str) -> RevokeOAuthGrantResponse: + """ + Revoke an existing OAuth grant. + see more: https://resend.com/docs/api-reference/oauth/revoke-grant + + Args: + oauth_grant_id (str): The ID of the OAuth grant to revoke + + Returns: + RevokeOAuthGrantResponse: The revoked OAuth grant response + """ + path = f"/oauth/grants/{oauth_grant_id}" + resp = request.Request[OAuthGrants.RevokeOAuthGrantResponse]( + path=path, params={}, verb="delete" + ).perform_with_content() + return resp + + @classmethod + async def list_async(cls, params: Optional[ListParams] = None) -> ListResponse: + """ + Retrieve a list of OAuth grants for the authenticated user (async). + see more: https://resend.com/docs/api-reference/oauth/list-grants + + Args: + params (Optional[ListParams]): Optional pagination parameters + + Returns: + ListResponse: A list of OAuth grant objects + """ + base_path = "/oauth/grants" + query_params = cast(Dict[Any, Any], params) if params else None + path = PaginationHelper.build_paginated_path(base_path, query_params) + resp = await AsyncRequest[OAuthGrants.ListResponse]( + path=path, params={}, verb="get" + ).perform_with_content() + return resp + + @classmethod + async def revoke_async(cls, oauth_grant_id: str) -> RevokeOAuthGrantResponse: + """ + Revoke an existing OAuth grant (async). + see more: https://resend.com/docs/api-reference/oauth/revoke-grant + + Args: + oauth_grant_id (str): The ID of the OAuth grant to revoke + + Returns: + RevokeOAuthGrantResponse: The revoked OAuth grant response + """ + path = f"/oauth/grants/{oauth_grant_id}" + resp = await AsyncRequest[OAuthGrants.RevokeOAuthGrantResponse]( + path=path, params={}, verb="delete" + ).perform_with_content() + return resp diff --git a/resend/oauth_grants/py.typed b/resend/oauth_grants/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/resend/version.py b/resend/version.py index 976d1a5..7e76ae5 100644 --- a/resend/version.py +++ b/resend/version.py @@ -1,4 +1,4 @@ -__version__ = "2.32.2" +__version__ = "2.33.0" def get_version() -> str: diff --git a/tests/oauth_grants_async_test.py b/tests/oauth_grants_async_test.py new file mode 100644 index 0000000..2ea82a4 --- /dev/null +++ b/tests/oauth_grants_async_test.py @@ -0,0 +1,74 @@ +import pytest + +import resend +from resend.exceptions import NoContentError +from tests.conftest import AsyncResendBaseTest + +# flake8: noqa + +pytestmark = pytest.mark.asyncio + + +class TestResendOAuthGrantsAsync(AsyncResendBaseTest): + async def test_oauth_grants_list_async(self) -> None: + self.set_mock_json( + { + "object": "list", + "has_more": False, + "data": [ + { + "id": "650e8400-e29b-41d4-a716-446655440001", + "client_id": "430eed87-632a-4ea6-90db-0aace67ec228", + "scopes": ["emails:send"], + "created_at": "2023-06-21T06:10:36.144Z", + "revoked_at": None, + "revoked_reason": None, + "client": { + "name": "Resend CLI", + "logo_uri": "https://example.com/logo.png", + }, + } + ], + } + ) + + grants: resend.OAuthGrants.ListResponse = await resend.OAuthGrants.list_async() + for grant in grants["data"]: + assert grant["id"] == "650e8400-e29b-41d4-a716-446655440001" + assert grant["client_id"] == "430eed87-632a-4ea6-90db-0aace67ec228" + assert grant["scopes"] == ["emails:send"] + assert grant["client"]["name"] == "Resend CLI" + + async def test_list_oauth_grants_async_returns_no_content_error( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.OAuthGrants.list_async() + + async def test_oauth_grants_revoke_async(self) -> None: + self.set_mock_json( + { + "object": "oauth_grant", + "id": "650e8400-e29b-41d4-a716-446655440001", + "revoked_at": "2023-06-22T06:10:36.144Z", + "revoked_reason": "revoked_from_api", + } + ) + + revoked: resend.OAuthGrants.RevokeOAuthGrantResponse = ( + await resend.OAuthGrants.revoke_async( + oauth_grant_id="650e8400-e29b-41d4-a716-446655440001", + ) + ) + assert revoked["object"] == "oauth_grant" + assert revoked["id"] == "650e8400-e29b-41d4-a716-446655440001" + assert revoked["revoked_at"] == "2023-06-22T06:10:36.144Z" + assert revoked["revoked_reason"] == "revoked_from_api" + + async def test_revoke_oauth_grant_async_returns_no_content_error( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.OAuthGrants.revoke_async(oauth_grant_id="grant-1") diff --git a/tests/oauth_grants_test.py b/tests/oauth_grants_test.py new file mode 100644 index 0000000..031b45b --- /dev/null +++ b/tests/oauth_grants_test.py @@ -0,0 +1,165 @@ +import resend +from resend.exceptions import NoContentError +from tests.conftest import ResendBaseTest + +# flake8: noqa + + +class TestResendOAuthGrants(ResendBaseTest): + def test_oauth_grants_list(self) -> None: + self.set_mock_json( + { + "object": "list", + "has_more": False, + "data": [ + { + "id": "650e8400-e29b-41d4-a716-446655440001", + "client_id": "430eed87-632a-4ea6-90db-0aace67ec228", + "scopes": ["emails:send"], + "created_at": "2023-06-21T06:10:36.144Z", + "revoked_at": None, + "revoked_reason": None, + "client": { + "name": "Resend CLI", + "logo_uri": "https://example.com/logo.png", + }, + } + ], + } + ) + + grants: resend.OAuthGrants.ListResponse = resend.OAuthGrants.list() + assert grants["object"] == "list" + assert grants["has_more"] is False + for grant in grants["data"]: + assert grant["id"] == "650e8400-e29b-41d4-a716-446655440001" + assert grant["client_id"] == "430eed87-632a-4ea6-90db-0aace67ec228" + assert grant["scopes"] == ["emails:send"] + assert grant["created_at"] == "2023-06-21T06:10:36.144Z" + assert grant["revoked_at"] is None + assert grant["revoked_reason"] is None + assert grant["client"]["name"] == "Resend CLI" + assert grant["client"]["logo_uri"] == "https://example.com/logo.png" + + def test_oauth_grants_list_revoked_grant(self) -> None: + self.set_mock_json( + { + "object": "list", + "has_more": False, + "data": [ + { + "id": "650e8400-e29b-41d4-a716-446655440002", + "client_id": "430eed87-632a-4ea6-90db-0aace67ec228", + "scopes": ["emails:send", "domains:read"], + "created_at": "2023-06-20T06:10:36.144Z", + "revoked_at": "2023-06-22T06:10:36.144Z", + "revoked_reason": "revoked_from_api", + "client": { + "name": "Resend CLI", + "logo_uri": None, + }, + } + ], + } + ) + + grants: resend.OAuthGrants.ListResponse = resend.OAuthGrants.list() + grant = grants["data"][0] + assert grant["revoked_at"] == "2023-06-22T06:10:36.144Z" + assert grant["revoked_reason"] == "revoked_from_api" + assert grant["client"]["logo_uri"] is None + + def test_list_oauth_grants_returns_no_content_error(self) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.OAuthGrants.list() + + def test_oauth_grants_list_with_pagination_params(self) -> None: + self.set_mock_json( + { + "object": "list", + "has_more": True, + "data": [ + { + "id": "grant-1", + "client_id": "client-1", + "scopes": ["emails:send"], + "created_at": "2023-06-21T06:10:36.144Z", + "revoked_at": None, + "revoked_reason": None, + "client": {"name": "Resend CLI", "logo_uri": None}, + }, + { + "id": "grant-2", + "client_id": "client-1", + "scopes": ["emails:send"], + "created_at": "2023-06-20T06:10:36.144Z", + "revoked_at": None, + "revoked_reason": None, + "client": {"name": "Resend CLI", "logo_uri": None}, + }, + ], + } + ) + + params: resend.OAuthGrants.ListParams = { + "limit": 10, + "after": "previous-grant-id", + } + grants: resend.OAuthGrants.ListResponse = resend.OAuthGrants.list(params=params) + assert grants["object"] == "list" + assert grants["has_more"] is True + assert len(grants["data"]) == 2 + assert grants["data"][0]["id"] == "grant-1" + assert grants["data"][1]["id"] == "grant-2" + + def test_oauth_grants_list_with_before_param(self) -> None: + self.set_mock_json( + { + "object": "list", + "has_more": False, + "data": [ + { + "id": "grant-3", + "client_id": "client-1", + "scopes": ["emails:send"], + "created_at": "2023-06-19T06:10:36.144Z", + "revoked_at": None, + "revoked_reason": None, + "client": {"name": "Resend CLI", "logo_uri": None}, + } + ], + } + ) + + params: resend.OAuthGrants.ListParams = {"limit": 5, "before": "later-grant-id"} + grants: resend.OAuthGrants.ListResponse = resend.OAuthGrants.list(params=params) + assert grants["object"] == "list" + assert grants["has_more"] is False + assert len(grants["data"]) == 1 + assert grants["data"][0]["id"] == "grant-3" + + def test_oauth_grants_revoke(self) -> None: + self.set_mock_json( + { + "object": "oauth_grant", + "id": "650e8400-e29b-41d4-a716-446655440001", + "revoked_at": "2023-06-22T06:10:36.144Z", + "revoked_reason": "revoked_from_api", + } + ) + + revoked: resend.OAuthGrants.RevokeOAuthGrantResponse = ( + resend.OAuthGrants.revoke( + oauth_grant_id="650e8400-e29b-41d4-a716-446655440001", + ) + ) + assert revoked["object"] == "oauth_grant" + assert revoked["id"] == "650e8400-e29b-41d4-a716-446655440001" + assert revoked["revoked_at"] == "2023-06-22T06:10:36.144Z" + assert revoked["revoked_reason"] == "revoked_from_api" + + def test_revoke_oauth_grant_returns_no_content_error(self) -> None: + self.set_mock_json(None) + with self.assertRaises(NoContentError): + _ = resend.OAuthGrants.revoke(oauth_grant_id="grant-1")