From 2e998a457c2987c9fef3d4b5e4abc4347c8232c5 Mon Sep 17 00:00:00 2001 From: Derich Pacheco Date: Tue, 11 Nov 2025 22:15:39 -0300 Subject: [PATCH 1/2] feat: add headers responsedict --- resend/request.py | 12 ++- resend/response.py | 31 +++++++ tests/response_headers_integration_test.py | 89 ++++++++++++++++++++ tests/response_test.py | 94 ++++++++++++++++++++++ tests/typing_test.py | 43 ++++++++++ 5 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 resend/response.py create mode 100644 tests/response_headers_integration_test.py create mode 100644 tests/response_test.py create mode 100644 tests/typing_test.py diff --git a/resend/request.py b/resend/request.py index 2f1af85..2924e93 100644 --- a/resend/request.py +++ b/resend/request.py @@ -6,6 +6,7 @@ import resend from resend.exceptions import (NoContentError, ResendError, raise_for_code_and_type) +from resend.response import ResponseDict from resend.version import get_version RequestVerb = Literal["get", "post", "put", "patch", "delete"] @@ -27,6 +28,7 @@ def __init__( self.params = params self.verb = verb self.options = options + self._response_headers: Dict[str, str] = {} def perform(self) -> Union[T, None]: data = self.make_request(url=f"{resend.api_url}{self.path}") @@ -90,6 +92,9 @@ def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]: suggested_action="Request failed, please try again.", ) + # Store response headers for later access + self._response_headers = dict(resp_headers) + content_type = {k.lower(): v for k, v in resp_headers.items()}.get( "content-type", "" ) @@ -102,7 +107,12 @@ def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]: ) try: - return cast(Union[Dict[str, Any], List[Any]], json.loads(content)) + parsed_data = cast(Union[Dict[str, Any], List[Any]], json.loads(content)) + # Wrap dict responses with ResponseDict to include headers + if isinstance(parsed_data, dict): + return ResponseDict(parsed_data, headers=self._response_headers) + # For list responses, return as-is (lists can't have attributes) + return parsed_data except json.JSONDecodeError: raise_for_code_and_type( code=500, diff --git a/resend/response.py b/resend/response.py new file mode 100644 index 0000000..f200bcd --- /dev/null +++ b/resend/response.py @@ -0,0 +1,31 @@ +"""Response wrapper that includes headers while maintaining backward compatibility.""" + +from typing import Any, Dict, Mapping, Optional + + +class ResponseDict(dict[str, Any]): + """A dictionary subclass that also carries response headers as an attribute. + + This class maintains full backward compatibility with existing code that + expects a plain dictionary, while also providing access to HTTP response + headers through the .headers attribute. + """ + + def __init__( + self, + data: Optional[Dict[str, Any]] = None, + headers: Optional[Mapping[str, str]] = None, + ): + """Initialize ResponseDict with data and optional headers. + + Args: + data: The response data dictionary + headers: The HTTP response headers + """ + super().__init__(data or {}) + self.headers: Dict[str, str] = dict(headers) if headers else {} + + def __repr__(self) -> str: + """Return a string representation including headers info.""" + dict_repr = dict.__repr__(self) + return f"ResponseDict({dict_repr}, headers={self.headers})" diff --git a/tests/response_headers_integration_test.py b/tests/response_headers_integration_test.py new file mode 100644 index 0000000..6f95c24 --- /dev/null +++ b/tests/response_headers_integration_test.py @@ -0,0 +1,89 @@ +import unittest +from unittest.mock import Mock + +import resend +from resend.response import ResponseDict + + +class TestResponseHeadersIntegration(unittest.TestCase): + def setUp(self) -> None: + """Set up test environment.""" + resend.api_key = "re_test_key" + + def test_email_send_response_includes_headers(self) -> None: + """Test that email send response includes headers.""" + # Mock the HTTP client to return headers + mock_client = Mock() + mock_client.request.return_value = ( + b'{"id": "email_123", "from": "test@example.com"}', + 200, + { + "content-type": "application/json", + "x-request-id": "req_abc123", + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "95", + "x-ratelimit-reset": "1699564800", + }, + ) + + # Replace default HTTP client with mock + original_client = resend.default_http_client + resend.default_http_client = mock_client + + try: + # Send email + response = resend.Emails.send( + { + "from": "test@example.com", + "to": "user@example.com", + "subject": "Test", + "html": "

Test

", + } + ) + + # Verify response is a ResponseDict + assert isinstance(response, ResponseDict) + assert isinstance(response, dict) + + # Verify backward compatibility - dict access still works + assert response["id"] == "email_123" + assert response.get("from") == "test@example.com" + + # Verify new feature - headers are accessible + assert hasattr(response, "headers") + assert response.headers["x-request-id"] == "req_abc123" + assert response.headers["x-ratelimit-limit"] == "100" + assert response.headers["x-ratelimit-remaining"] == "95" + assert response.headers["x-ratelimit-reset"] == "1699564800" + + finally: + # Restore original HTTP client + resend.default_http_client = original_client + + def test_list_response_headers(self) -> None: + """Test that list responses work (without ResponseDict wrapping).""" + # Mock the HTTP client to return a list + mock_client = Mock() + mock_client.request.return_value = ( + b'{"data": [{"id": "1"}, {"id": "2"}]}', + 200, + { + "content-type": "application/json", + "x-request-id": "req_xyz", + }, + ) + + original_client = resend.default_http_client + resend.default_http_client = mock_client + + try: + # Get API keys list + response = resend.ApiKeys.list() + + # List responses are ResponseDict with data field + assert isinstance(response, ResponseDict) + assert "data" in response + assert response.headers["x-request-id"] == "req_xyz" + + finally: + resend.default_http_client = original_client diff --git a/tests/response_test.py b/tests/response_test.py new file mode 100644 index 0000000..e44027d --- /dev/null +++ b/tests/response_test.py @@ -0,0 +1,94 @@ +import unittest + +from resend.response import ResponseDict + + +class TestResponseDict(unittest.TestCase): + def test_response_dict_behaves_like_dict(self) -> None: + """Test that ResponseDict acts exactly like a regular dict.""" + data = {"id": "123", "status": "sent"} + response = ResponseDict(data, headers={"x-request-id": "abc"}) + + # Dict access + assert response["id"] == "123" + assert response["status"] == "sent" + assert response.get("id") == "123" + assert response.get("missing", "default") == "default" + + # Dict methods + assert list(response.keys()) == ["id", "status"] + assert list(response.values()) == ["123", "sent"] + assert len(response) == 2 + + # Iteration + keys = [] + for key in response: + keys.append(key) + assert keys == ["id", "status"] + + # Is instance of dict + assert isinstance(response, dict) + + def test_response_dict_headers_access(self) -> None: + """Test that headers can be accessed via .headers attribute.""" + data = {"id": "123"} + headers = { + "x-request-id": "req_abc", + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "95", + } + response = ResponseDict(data, headers=headers) + + assert response.headers["x-request-id"] == "req_abc" + assert response.headers["x-ratelimit-limit"] == "100" + assert response.headers.get("x-ratelimit-remaining") == "95" + assert response.headers.get("missing") is None + + def test_response_dict_empty_headers(self) -> None: + """Test ResponseDict with no headers.""" + data = {"id": "123"} + response = ResponseDict(data) + + assert response.headers == {} + assert response["id"] == "123" + + def test_response_dict_empty_data(self) -> None: + """Test ResponseDict with empty data.""" + response = ResponseDict({}, headers={"x-test": "value"}) + + assert len(response) == 0 + assert response.headers["x-test"] == "value" + + def test_response_dict_equality(self) -> None: + """Test that ResponseDict compares equal to equivalent dicts.""" + data = {"id": "123", "status": "sent"} + response = ResponseDict(data, headers={"x-test": "value"}) + + assert response == data + assert response == {"id": "123", "status": "sent"} + + def test_response_dict_repr(self) -> None: + """Test string representation of ResponseDict.""" + data = {"id": "123"} + headers = {"x-request-id": "abc"} + response = ResponseDict(data, headers=headers) + + repr_str = repr(response) + assert "ResponseDict" in repr_str + assert "'id': '123'" in repr_str + assert "x-request-id" in repr_str + + def test_response_dict_modification(self) -> None: + """Test that ResponseDict can be modified like a regular dict.""" + response = ResponseDict({"id": "123"}, headers={"x-test": "value"}) + + # Modify dict + response["new_key"] = "new_value" + assert response["new_key"] == "new_value" + + # Update dict + response.update({"another": "field"}) + assert response["another"] == "field" + + # Headers remain intact + assert response.headers["x-test"] == "value" diff --git a/tests/typing_test.py b/tests/typing_test.py new file mode 100644 index 0000000..2f2fd91 --- /dev/null +++ b/tests/typing_test.py @@ -0,0 +1,43 @@ +"""Test that ResponseDict is compatible with TypedDict type hints.""" +from typing_extensions import TypedDict + +from resend.response import ResponseDict + + +class EmailResponse(TypedDict): + """Example typed dict like SendResponse.""" + id: str + status: str + + +def test_response_dict_with_typed_dict() -> None: + """Test that ResponseDict works with TypedDict type hints.""" + # Create a ResponseDict + data = {"id": "123", "status": "sent"} + response: EmailResponse = ResponseDict(data, headers={"x-test": "value"}) # type: ignore + + # Type checkers should accept this since ResponseDict is a dict + assert response["id"] == "123" + assert response["status"] == "sent" + + # Headers are accessible + assert isinstance(response, ResponseDict) + if isinstance(response, ResponseDict): + assert response.headers["x-test"] == "value" + + +def returns_typed_dict() -> EmailResponse: + """Function that returns a TypedDict.""" + return ResponseDict({"id": "456", "status": "delivered"}, headers={"x-id": "abc"}) # type: ignore + + +def test_function_return_type() -> None: + """Test that functions can return ResponseDict where TypedDict is expected.""" + result = returns_typed_dict() + + # Works as TypedDict + assert result["id"] == "456" + + # Can check instance and access headers + if isinstance(result, ResponseDict): + assert result.headers["x-id"] == "abc" From 557e2cddbebbbd77980d37bc8cb446202c7af097 Mon Sep 17 00:00:00 2001 From: Derich Pacheco Date: Fri, 14 Nov 2025 09:43:53 -0300 Subject: [PATCH 2/2] try a different approach --- examples/domains.py | 4 +- examples/with_headers.py | 70 ++++++++++++++ resend/_base_response.py | 16 ++++ resend/api_keys/_api_keys.py | 5 +- resend/audiences/_audiences.py | 7 +- resend/broadcasts/_broadcasts.py | 9 +- .../contact_properties/_contact_properties.py | 9 +- resend/contacts/_contacts.py | 9 +- resend/contacts/_topics.py | 5 +- resend/contacts/segments/_contact_segments.py | 7 +- resend/domains/_domains.py | 5 +- resend/emails/_attachments.py | 3 +- resend/emails/_batch.py | 5 +- resend/emails/_emails.py | 13 ++- resend/emails/_receiving.py | 5 +- resend/request.py | 7 +- resend/response.py | 31 ------ resend/segments/_segments.py | 7 +- resend/templates/_templates.py | 13 +-- resend/topics/_topics.py | 9 +- resend/webhooks/_webhooks.py | 9 +- tests/exceptions_test.py | 4 +- tests/response_headers_integration_test.py | 26 ++--- tests/response_test.py | 94 ------------------- tests/typing_test.py | 43 --------- 25 files changed, 176 insertions(+), 239 deletions(-) create mode 100644 examples/with_headers.py create mode 100644 resend/_base_response.py delete mode 100644 resend/response.py delete mode 100644 tests/response_test.py delete mode 100644 tests/typing_test.py diff --git a/examples/domains.py b/examples/domains.py index 1f530b0..520c5c7 100644 --- a/examples/domains.py +++ b/examples/domains.py @@ -37,8 +37,8 @@ print(f"Has more domains: {domains['has_more']}") if not domains["data"]: print("No domains found") -for domain in domains["data"]: - print(domain) +for listed_domain in domains["data"]: + print(listed_domain) print("\n--- Using pagination parameters ---") if domains["data"]: diff --git a/examples/with_headers.py b/examples/with_headers.py new file mode 100644 index 0000000..fbee52e --- /dev/null +++ b/examples/with_headers.py @@ -0,0 +1,70 @@ +""" +Example demonstrating how to access response headers. + +Response headers include useful information like rate limits, request IDs, etc. +""" + +import os + +import resend + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + +params: resend.Emails.SendParams = { + "from": "onboarding@resend.dev", + "to": ["delivered@resend.dev"], + "subject": "Hello from Resend", + "html": "Hello, world!", +} + +print("=" * 60) +print("Example 1: Without type annotations") +print("=" * 60) + +response = resend.Emails.send(params) +print(f"Email sent! ID: {response['id']}") +print(f"Request ID: {response['headers'].get('x-request-id')}") +print(f"Rate limit: {response['headers'].get('x-ratelimit-limit')}") +print(f"Rate limit remaining: {response['headers'].get('x-ratelimit-remaining')}") +print(f"Rate limit reset: {response['headers'].get('x-ratelimit-reset')}") + +print("\n" + "=" * 60) +print("Example 2: With type annotations") +print("=" * 60) + +typed_response: resend.Emails.SendResponse = resend.Emails.send(params) +print(f"Email sent! ID: {typed_response['id']}") + +if "headers" in typed_response: + print(f"Request ID: {typed_response['headers'].get('x-request-id')}") + print(f"Rate limit: {typed_response['headers'].get('x-ratelimit-limit')}") + print( + f"Rate limit remaining: {typed_response['headers'].get('x-ratelimit-remaining')}" + ) + print(f"Rate limit reset: {typed_response['headers'].get('x-ratelimit-reset')}") + +print("\n" + "=" * 60) +print("Example 3: Rate limit tracking") +print("=" * 60) + + +def send_with_rate_limit_check(params: resend.Emails.SendParams) -> str: + """Example function showing how to track rate limits.""" + response = resend.Emails.send(params) + + # Access headers via dict key + headers = response.get("headers", {}) + remaining = headers.get("x-ratelimit-remaining") + limit = headers.get("x-ratelimit-limit") + + if remaining and limit: + print(f"Rate limit usage: {int(limit) - int(remaining)}/{limit}") + if int(remaining) < 10: + print("⚠️ Warning: Approaching rate limit!") + + return response["id"] + + +email_id = send_with_rate_limit_check(params) +print(f"Sent email with ID: {email_id}") diff --git a/resend/_base_response.py b/resend/_base_response.py new file mode 100644 index 0000000..47ac531 --- /dev/null +++ b/resend/_base_response.py @@ -0,0 +1,16 @@ +"""Base response type for all Resend API responses.""" + +from typing import Dict + +from typing_extensions import NotRequired, TypedDict + + +class BaseResponse(TypedDict): + """Base response type that all API responses inherit from. + + Attributes: + headers: HTTP response headers including rate limit info, request IDs, etc. + Optional field that may not be present in all responses. + """ + + headers: NotRequired[Dict[str, str]] diff --git a/resend/api_keys/_api_keys.py b/resend/api_keys/_api_keys.py index 843cf8c..30a2583 100644 --- a/resend/api_keys/_api_keys.py +++ b/resend/api_keys/_api_keys.py @@ -3,13 +3,14 @@ from typing_extensions import NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.api_keys._api_key import ApiKey from resend.pagination_helper import PaginationHelper class ApiKeys: - class ListResponse(TypedDict): + class ListResponse(BaseResponse): """ ListResponse type that wraps a list of API key objects with pagination metadata @@ -32,7 +33,7 @@ class ListResponse(TypedDict): Whether there are more results available for pagination """ - class CreateApiKeyResponse(TypedDict): + class CreateApiKeyResponse(BaseResponse): """ CreateApiKeyResponse is the type that wraps the response of the API key that was created diff --git a/resend/audiences/_audiences.py b/resend/audiences/_audiences.py index 95a6116..cd4cb7e 100644 --- a/resend/audiences/_audiences.py +++ b/resend/audiences/_audiences.py @@ -3,6 +3,7 @@ from typing_extensions import NotRequired, TypedDict +from resend._base_response import BaseResponse from resend.segments._segments import Segments from ._audience import Audience @@ -10,7 +11,7 @@ class Audiences: - class RemoveAudienceResponse(TypedDict): + class RemoveAudienceResponse(BaseResponse): """ RemoveAudienceResponse is the type that wraps the response of the audience that was removed @@ -51,7 +52,7 @@ class ListParams(TypedDict): Cannot be used with the after parameter. """ - class ListResponse(TypedDict): + class ListResponse(BaseResponse): """ ListResponse type that wraps a list of audience objects with pagination metadata @@ -74,7 +75,7 @@ class ListResponse(TypedDict): Whether there are more results available for pagination """ - class CreateAudienceResponse(TypedDict): + class CreateAudienceResponse(BaseResponse): """ CreateAudienceResponse is the type that wraps the response of the audience that was created diff --git a/resend/broadcasts/_broadcasts.py b/resend/broadcasts/_broadcasts.py index 676ebc6..285e59e 100644 --- a/resend/broadcasts/_broadcasts.py +++ b/resend/broadcasts/_broadcasts.py @@ -3,6 +3,7 @@ from typing_extensions import NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.pagination_helper import PaginationHelper from ._broadcast import Broadcast @@ -143,7 +144,7 @@ class SendParams(TypedDict): The date should be in natural language (e.g.: in 1 min) or ISO 8601 format (e.g: 2024-08-05T11:52:01.858Z). """ - class CreateResponse(TypedDict): + class CreateResponse(BaseResponse): """ CreateResponse is the class that wraps the response of the create method. @@ -156,7 +157,7 @@ class CreateResponse(TypedDict): id of the created broadcast """ - class UpdateResponse(TypedDict): + class UpdateResponse(BaseResponse): """ UpdateResponse is the class that wraps the response of the update method. @@ -195,7 +196,7 @@ class ListParams(TypedDict): Cannot be used with the after parameter. """ - class ListResponse(TypedDict): + class ListResponse(BaseResponse): """ ListResponse is the class that wraps the response of the list method with pagination metadata. @@ -218,7 +219,7 @@ class ListResponse(TypedDict): Whether there are more results available for pagination """ - class RemoveResponse(TypedDict): + class RemoveResponse(BaseResponse): """ RemoveResponse is the class that wraps the response of the remove method. diff --git a/resend/contact_properties/_contact_properties.py b/resend/contact_properties/_contact_properties.py index 2e6bccf..b649e98 100644 --- a/resend/contact_properties/_contact_properties.py +++ b/resend/contact_properties/_contact_properties.py @@ -3,6 +3,7 @@ from typing_extensions import NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.pagination_helper import PaginationHelper from ._contact_property import ContactProperty @@ -10,7 +11,7 @@ class ContactProperties: - class CreateResponse(TypedDict): + class CreateResponse(BaseResponse): """ CreateResponse is the type that wraps the response of the contact property that was created. @@ -28,7 +29,7 @@ class CreateResponse(TypedDict): The object type, always "contact_property". """ - class UpdateResponse(TypedDict): + class UpdateResponse(BaseResponse): """ UpdateResponse is the type that wraps the response of the contact property that was updated. @@ -46,7 +47,7 @@ class UpdateResponse(TypedDict): The object type, always "contact_property". """ - class RemoveResponse(TypedDict): + class RemoveResponse(BaseResponse): """ RemoveResponse is the type that wraps the response of the contact property that was removed. @@ -96,7 +97,7 @@ class ListParams(TypedDict): Cannot be used with the after parameter. """ - class ListResponse(TypedDict): + class ListResponse(BaseResponse): """ ListResponse type that wraps a list of contact property objects with pagination metadata. diff --git a/resend/contacts/_contacts.py b/resend/contacts/_contacts.py index 2468af4..e166321 100644 --- a/resend/contacts/_contacts.py +++ b/resend/contacts/_contacts.py @@ -3,6 +3,7 @@ from typing_extensions import NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.pagination_helper import PaginationHelper from ._contact import Contact @@ -15,7 +16,7 @@ class Contacts: Segments = ContactSegments Topics = Topics - class RemoveContactResponse(TypedDict): + class RemoveContactResponse(BaseResponse): """ RemoveContactResponse is the type that wraps the response of the contact that was removed @@ -56,7 +57,7 @@ class ListParams(TypedDict): Cannot be used with the after parameter. """ - class ListResponse(TypedDict): + class ListResponse(BaseResponse): """ ListResponse type that wraps a list of contact objects with pagination metadata @@ -79,7 +80,7 @@ class ListResponse(TypedDict): Whether there are more results available for pagination """ - class CreateContactResponse(TypedDict): + class CreateContactResponse(BaseResponse): """ CreateContactResponse is the type that wraps the response of the contact that was created @@ -97,7 +98,7 @@ class CreateContactResponse(TypedDict): The ID of the scheduled email that was canceled. """ - class UpdateContactResponse(TypedDict): + class UpdateContactResponse(BaseResponse): """ UpdateContactResponse is the type that wraps the response of the contact that was updated diff --git a/resend/contacts/_topics.py b/resend/contacts/_topics.py index 0e414bd..d178e04 100644 --- a/resend/contacts/_topics.py +++ b/resend/contacts/_topics.py @@ -3,6 +3,7 @@ from typing_extensions import NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.pagination_helper import PaginationHelper from ._contact_topic import ContactTopic, TopicSubscriptionUpdate @@ -23,7 +24,7 @@ class _ListParams(TypedDict): """ -class _ListResponse(TypedDict): +class _ListResponse(BaseResponse): object: str """ The object type: "list" @@ -53,7 +54,7 @@ class _UpdateParams(TypedDict): """ -class _UpdateResponse(TypedDict): +class _UpdateResponse(BaseResponse): id: str """ The contact ID. diff --git a/resend/contacts/segments/_contact_segments.py b/resend/contacts/segments/_contact_segments.py index 854b6f5..f0dae00 100644 --- a/resend/contacts/segments/_contact_segments.py +++ b/resend/contacts/segments/_contact_segments.py @@ -3,6 +3,7 @@ from typing_extensions import NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.pagination_helper import PaginationHelper from ._contact_segment import ContactSegment @@ -14,7 +15,7 @@ class ContactSegments: This is separate from the main Contacts API which uses audience_id. """ - class AddContactSegmentResponse(TypedDict): + class AddContactSegmentResponse(BaseResponse): """ AddContactSegmentResponse is the type that wraps the response when adding a contact to a segment. @@ -27,7 +28,7 @@ class AddContactSegmentResponse(TypedDict): The ID of the contact segment association. """ - class RemoveContactSegmentResponse(TypedDict): + class RemoveContactSegmentResponse(BaseResponse): """ RemoveContactSegmentResponse is the type that wraps the response when removing a contact from a segment. @@ -72,7 +73,7 @@ class ListContactSegmentsParams(TypedDict): Cannot be used with the after parameter. """ - class ListContactSegmentsResponse(TypedDict): + class ListContactSegmentsResponse(BaseResponse): """ ListContactSegmentsResponse type that wraps a list of segment objects with pagination metadata. diff --git a/resend/domains/_domains.py b/resend/domains/_domains.py index 39cb85f..c1bf090 100644 --- a/resend/domains/_domains.py +++ b/resend/domains/_domains.py @@ -3,6 +3,7 @@ from typing_extensions import Literal, NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.domains._domain import Domain from resend.domains._record import Record from resend.pagination_helper import PaginationHelper @@ -30,7 +31,7 @@ class ListParams(TypedDict): Cannot be used with the after parameter. """ - class ListResponse(TypedDict): + class ListResponse(BaseResponse): """ ListResponse type that wraps a list of domain objects with pagination metadata @@ -53,7 +54,7 @@ class ListResponse(TypedDict): Whether there are more results available for pagination """ - class CreateDomainResponse(TypedDict): + class CreateDomainResponse(BaseResponse): """ CreateDomainResponse is the type that wraps the response of the domain that was created diff --git a/resend/emails/_attachments.py b/resend/emails/_attachments.py index c7172e9..a8568dd 100644 --- a/resend/emails/_attachments.py +++ b/resend/emails/_attachments.py @@ -3,6 +3,7 @@ from typing_extensions import NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.emails._received_email import (EmailAttachment, EmailAttachmentDetails) from resend.pagination_helper import PaginationHelper @@ -23,7 +24,7 @@ class _ListParams(TypedDict): """ -class _ListResponse(TypedDict): +class _ListResponse(BaseResponse): object: str """ The object type: "list" diff --git a/resend/emails/_batch.py b/resend/emails/_batch.py index 1fdd55c..f8d5f69 100644 --- a/resend/emails/_batch.py +++ b/resend/emails/_batch.py @@ -3,11 +3,12 @@ from typing_extensions import Literal, NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from ._emails import Emails -class SendEmailResponse(TypedDict): +class SendEmailResponse(BaseResponse): id: str """ The sent Email ID. @@ -60,7 +61,7 @@ class SendOptions(TypedDict): Defaults to "strict" when not provided. """ - class SendResponse(TypedDict): + class SendResponse(BaseResponse): data: List[SendEmailResponse] """ A list of email objects diff --git a/resend/emails/_emails.py b/resend/emails/_emails.py index eedba0f..9f7416a 100644 --- a/resend/emails/_emails.py +++ b/resend/emails/_emails.py @@ -3,6 +3,7 @@ from typing_extensions import NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.emails._attachment import Attachment, RemoteAttachment from resend.emails._attachments import Attachments from resend.emails._email import Email @@ -42,7 +43,7 @@ class _UpdateParams(TypedDict): """ -class _UpdateEmailResponse(TypedDict): +class _UpdateEmailResponse(BaseResponse): object: str """ The object type: email @@ -53,7 +54,7 @@ class _UpdateEmailResponse(TypedDict): """ -class _CancelScheduledEmailResponse(TypedDict): +class _CancelScheduledEmailResponse(BaseResponse): object: str """ The object type: email @@ -194,12 +195,13 @@ class SendOptions(TypedDict): If provided, will be sent as the `Idempotency-Key` header. """ - class SendResponse(TypedDict): + class SendResponse(BaseResponse): """ SendResponse is the type that wraps the response of the email that was sent. Attributes: id (str): The ID of the sent email + headers (NotRequired[Dict[str, str]]): HTTP response headers (inherited from BaseResponse) """ id: str @@ -230,7 +232,7 @@ class ListParams(TypedDict): Return emails before this cursor for pagination. """ - class ListResponse(TypedDict): + class ListResponse(BaseResponse): """ ListResponse is the type that wraps the response for listing emails. @@ -238,6 +240,7 @@ class ListResponse(TypedDict): object (str): The object type: "list" data (List[Email]): The list of email objects. has_more (bool): Whether there are more emails available for pagination. + headers (NotRequired[Dict[str, str]]): HTTP response headers (inherited from BaseResponse) """ object: str @@ -269,7 +272,7 @@ def send( id: The ID of the sent email """ path = "/emails" - resp = request.Request[Email]( + resp = request.Request[Emails.SendResponse]( path=path, params=cast(Dict[Any, Any], params), verb="post", diff --git a/resend/emails/_receiving.py b/resend/emails/_receiving.py index 7eed6ec..97c98d3 100644 --- a/resend/emails/_receiving.py +++ b/resend/emails/_receiving.py @@ -3,6 +3,7 @@ from typing_extensions import NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.emails._received_email import (EmailAttachment, EmailAttachmentDetails, ListReceivedEmail, ReceivedEmail) @@ -24,7 +25,7 @@ class _ListParams(TypedDict): """ -class _ListResponse(TypedDict): +class _ListResponse(BaseResponse): object: str """ The object type: "list" @@ -54,7 +55,7 @@ class _AttachmentListParams(TypedDict): """ -class _AttachmentListResponse(TypedDict): +class _AttachmentListResponse(BaseResponse): object: str """ The object type: "list" diff --git a/resend/request.py b/resend/request.py index 2924e93..a0334ae 100644 --- a/resend/request.py +++ b/resend/request.py @@ -6,7 +6,6 @@ import resend from resend.exceptions import (NoContentError, ResendError, raise_for_code_and_type) -from resend.response import ResponseDict from resend.version import get_version RequestVerb = Literal["get", "post", "put", "patch", "delete"] @@ -108,10 +107,10 @@ def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]: try: parsed_data = cast(Union[Dict[str, Any], List[Any]], json.loads(content)) - # Wrap dict responses with ResponseDict to include headers + # Inject headers into dict responses if isinstance(parsed_data, dict): - return ResponseDict(parsed_data, headers=self._response_headers) - # For list responses, return as-is (lists can't have attributes) + parsed_data["headers"] = dict(self._response_headers) + # For list responses, return as-is (lists can't have headers key) return parsed_data except json.JSONDecodeError: raise_for_code_and_type( diff --git a/resend/response.py b/resend/response.py deleted file mode 100644 index f200bcd..0000000 --- a/resend/response.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Response wrapper that includes headers while maintaining backward compatibility.""" - -from typing import Any, Dict, Mapping, Optional - - -class ResponseDict(dict[str, Any]): - """A dictionary subclass that also carries response headers as an attribute. - - This class maintains full backward compatibility with existing code that - expects a plain dictionary, while also providing access to HTTP response - headers through the .headers attribute. - """ - - def __init__( - self, - data: Optional[Dict[str, Any]] = None, - headers: Optional[Mapping[str, str]] = None, - ): - """Initialize ResponseDict with data and optional headers. - - Args: - data: The response data dictionary - headers: The HTTP response headers - """ - super().__init__(data or {}) - self.headers: Dict[str, str] = dict(headers) if headers else {} - - def __repr__(self) -> str: - """Return a string representation including headers info.""" - dict_repr = dict.__repr__(self) - return f"ResponseDict({dict_repr}, headers={self.headers})" diff --git a/resend/segments/_segments.py b/resend/segments/_segments.py index 4dadd0f..3f2dbfa 100644 --- a/resend/segments/_segments.py +++ b/resend/segments/_segments.py @@ -3,6 +3,7 @@ from typing_extensions import NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.pagination_helper import PaginationHelper from ._segment import Segment @@ -10,7 +11,7 @@ class Segments: - class RemoveSegmentResponse(TypedDict): + class RemoveSegmentResponse(BaseResponse): """ RemoveSegmentResponse is the type that wraps the response of the segment that was removed @@ -51,7 +52,7 @@ class ListParams(TypedDict): Cannot be used with the after parameter. """ - class ListResponse(TypedDict): + class ListResponse(BaseResponse): """ ListResponse type that wraps a list of segment objects with pagination metadata @@ -74,7 +75,7 @@ class ListResponse(TypedDict): Whether there are more results available for pagination """ - class CreateSegmentResponse(TypedDict): + class CreateSegmentResponse(BaseResponse): """ CreateSegmentResponse is the type that wraps the response of the segment that was created diff --git a/resend/templates/_templates.py b/resend/templates/_templates.py index 64b7bb4..3532d44 100644 --- a/resend/templates/_templates.py +++ b/resend/templates/_templates.py @@ -5,6 +5,7 @@ from typing_extensions import NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.pagination_helper import PaginationHelper from ._template import Template, TemplateListItem, Variable @@ -68,7 +69,7 @@ class CreateParams(_CreateParamsFrom): variables: NotRequired[List[Variable]] """The array of variables used in the template.""" - class CreateResponse(TypedDict): + class CreateResponse(BaseResponse): """Response from creating a template. Attributes: @@ -121,7 +122,7 @@ class UpdateParams(_CreateParamsFrom): variables: NotRequired[List[Variable]] """The array of variables used in the template.""" - class UpdateResponse(TypedDict): + class UpdateResponse(BaseResponse): """Response from updating a template. Attributes: @@ -153,7 +154,7 @@ class ListParams(TypedDict): before: NotRequired[str] """Return templates before this cursor.""" - class ListResponse(TypedDict): + class ListResponse(BaseResponse): """Response from listing templates. Attributes: @@ -171,7 +172,7 @@ class ListResponse(TypedDict): has_more: bool """Whether there are more results available.""" - class PublishResponse(TypedDict): + class PublishResponse(BaseResponse): """Response from publishing a template. Attributes: @@ -185,7 +186,7 @@ class PublishResponse(TypedDict): object: str """The object type (always "template").""" - class DuplicateResponse(TypedDict): + class DuplicateResponse(BaseResponse): """Response from duplicating a template. Attributes: @@ -199,7 +200,7 @@ class DuplicateResponse(TypedDict): object: str """The object type (always "template").""" - class RemoveResponse(TypedDict): + class RemoveResponse(BaseResponse): """Response from removing a template. Attributes: diff --git a/resend/topics/_topics.py b/resend/topics/_topics.py index 19e535b..e724edc 100644 --- a/resend/topics/_topics.py +++ b/resend/topics/_topics.py @@ -3,6 +3,7 @@ from typing_extensions import NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.pagination_helper import PaginationHelper from ._topic import Topic @@ -10,7 +11,7 @@ class Topics: - class CreateTopicResponse(TypedDict): + class CreateTopicResponse(BaseResponse): """ CreateTopicResponse is the type that wraps the response of the topic that was created @@ -38,7 +39,7 @@ class CreateParams(TypedDict): The topic description. Max length is 200 characters. """ - class UpdateTopicResponse(TypedDict): + class UpdateTopicResponse(BaseResponse): """ UpdateTopicResponse is the type that wraps the response of the topic that was updated @@ -61,7 +62,7 @@ class UpdateParams(TypedDict, total=False): The topic description. Max length is 200 characters. """ - class RemoveTopicResponse(TypedDict): + class RemoveTopicResponse(BaseResponse): """ RemoveTopicResponse is the type that wraps the response of the topic that was removed @@ -102,7 +103,7 @@ class ListParams(TypedDict): Cannot be used with the after parameter. """ - class ListResponse(TypedDict): + class ListResponse(BaseResponse): """ ListResponse type that wraps a list of topic objects with pagination metadata diff --git a/resend/webhooks/_webhooks.py b/resend/webhooks/_webhooks.py index caa5d74..1abf0ea 100644 --- a/resend/webhooks/_webhooks.py +++ b/resend/webhooks/_webhooks.py @@ -7,6 +7,7 @@ from typing_extensions import NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.pagination_helper import PaginationHelper from resend.webhooks._webhook import (VerifyWebhookOptions, Webhook, WebhookEvent, WebhookStatus) @@ -35,7 +36,7 @@ class ListParams(TypedDict): Cannot be used with the after parameter. """ - class ListResponse(TypedDict): + class ListResponse(BaseResponse): """ ListResponse type that wraps a list of webhook objects with pagination metadata @@ -58,7 +59,7 @@ class ListResponse(TypedDict): Whether there are more results available for pagination """ - class CreateWebhookResponse(TypedDict): + class CreateWebhookResponse(BaseResponse): """ CreateWebhookResponse is the type that wraps the response of the webhook that was created @@ -110,7 +111,7 @@ class UpdateParams(TypedDict): The webhook status. Can be either "enabled" or "disabled". """ - class UpdateWebhookResponse(TypedDict): + class UpdateWebhookResponse(BaseResponse): """ UpdateWebhookResponse is the type that wraps the response of the webhook that was updated @@ -128,7 +129,7 @@ class UpdateWebhookResponse(TypedDict): The ID of the updated webhook """ - class DeleteWebhookResponse(TypedDict): + class DeleteWebhookResponse(BaseResponse): """ DeleteWebhookResponse is the type that wraps the response of the webhook that was deleted diff --git a/tests/exceptions_test.py b/tests/exceptions_test.py index cb76542..bf34089 100644 --- a/tests/exceptions_test.py +++ b/tests/exceptions_test.py @@ -49,7 +49,9 @@ def test_daily_quota_exceeded_error(self) -> None: def test_monthly_quota_exceeded_error(self) -> None: with pytest.raises(RateLimitError) as e: - raise_for_code_and_type(429, "monthly_quota_exceeded", "Monthly quota exceeded") + raise_for_code_and_type( + 429, "monthly_quota_exceeded", "Monthly quota exceeded" + ) assert e.type is RateLimitError assert e.value.code == 429 assert e.value.error_type == "monthly_quota_exceeded" diff --git a/tests/response_headers_integration_test.py b/tests/response_headers_integration_test.py index 6f95c24..2816025 100644 --- a/tests/response_headers_integration_test.py +++ b/tests/response_headers_integration_test.py @@ -2,7 +2,6 @@ from unittest.mock import Mock import resend -from resend.response import ResponseDict class TestResponseHeadersIntegration(unittest.TestCase): @@ -41,27 +40,26 @@ def test_email_send_response_includes_headers(self) -> None: } ) - # Verify response is a ResponseDict - assert isinstance(response, ResponseDict) + # Verify response is a dict assert isinstance(response, dict) # Verify backward compatibility - dict access still works assert response["id"] == "email_123" assert response.get("from") == "test@example.com" - # Verify new feature - headers are accessible - assert hasattr(response, "headers") - assert response.headers["x-request-id"] == "req_abc123" - assert response.headers["x-ratelimit-limit"] == "100" - assert response.headers["x-ratelimit-remaining"] == "95" - assert response.headers["x-ratelimit-reset"] == "1699564800" + # Verify new feature - headers are accessible via dict key + assert "headers" in response + assert response["headers"]["x-request-id"] == "req_abc123" + assert response["headers"]["x-ratelimit-limit"] == "100" + assert response["headers"]["x-ratelimit-remaining"] == "95" + assert response["headers"]["x-ratelimit-reset"] == "1699564800" finally: # Restore original HTTP client resend.default_http_client = original_client def test_list_response_headers(self) -> None: - """Test that list responses work (without ResponseDict wrapping).""" + """Test that list responses include headers.""" # Mock the HTTP client to return a list mock_client = Mock() mock_client.request.return_value = ( @@ -80,10 +78,12 @@ def test_list_response_headers(self) -> None: # Get API keys list response = resend.ApiKeys.list() - # List responses are ResponseDict with data field - assert isinstance(response, ResponseDict) + # List responses are dicts with data field + assert isinstance(response, dict) assert "data" in response - assert response.headers["x-request-id"] == "req_xyz" + # Headers are injected into the dict + assert "headers" in response + assert response["headers"]["x-request-id"] == "req_xyz" finally: resend.default_http_client = original_client diff --git a/tests/response_test.py b/tests/response_test.py deleted file mode 100644 index e44027d..0000000 --- a/tests/response_test.py +++ /dev/null @@ -1,94 +0,0 @@ -import unittest - -from resend.response import ResponseDict - - -class TestResponseDict(unittest.TestCase): - def test_response_dict_behaves_like_dict(self) -> None: - """Test that ResponseDict acts exactly like a regular dict.""" - data = {"id": "123", "status": "sent"} - response = ResponseDict(data, headers={"x-request-id": "abc"}) - - # Dict access - assert response["id"] == "123" - assert response["status"] == "sent" - assert response.get("id") == "123" - assert response.get("missing", "default") == "default" - - # Dict methods - assert list(response.keys()) == ["id", "status"] - assert list(response.values()) == ["123", "sent"] - assert len(response) == 2 - - # Iteration - keys = [] - for key in response: - keys.append(key) - assert keys == ["id", "status"] - - # Is instance of dict - assert isinstance(response, dict) - - def test_response_dict_headers_access(self) -> None: - """Test that headers can be accessed via .headers attribute.""" - data = {"id": "123"} - headers = { - "x-request-id": "req_abc", - "x-ratelimit-limit": "100", - "x-ratelimit-remaining": "95", - } - response = ResponseDict(data, headers=headers) - - assert response.headers["x-request-id"] == "req_abc" - assert response.headers["x-ratelimit-limit"] == "100" - assert response.headers.get("x-ratelimit-remaining") == "95" - assert response.headers.get("missing") is None - - def test_response_dict_empty_headers(self) -> None: - """Test ResponseDict with no headers.""" - data = {"id": "123"} - response = ResponseDict(data) - - assert response.headers == {} - assert response["id"] == "123" - - def test_response_dict_empty_data(self) -> None: - """Test ResponseDict with empty data.""" - response = ResponseDict({}, headers={"x-test": "value"}) - - assert len(response) == 0 - assert response.headers["x-test"] == "value" - - def test_response_dict_equality(self) -> None: - """Test that ResponseDict compares equal to equivalent dicts.""" - data = {"id": "123", "status": "sent"} - response = ResponseDict(data, headers={"x-test": "value"}) - - assert response == data - assert response == {"id": "123", "status": "sent"} - - def test_response_dict_repr(self) -> None: - """Test string representation of ResponseDict.""" - data = {"id": "123"} - headers = {"x-request-id": "abc"} - response = ResponseDict(data, headers=headers) - - repr_str = repr(response) - assert "ResponseDict" in repr_str - assert "'id': '123'" in repr_str - assert "x-request-id" in repr_str - - def test_response_dict_modification(self) -> None: - """Test that ResponseDict can be modified like a regular dict.""" - response = ResponseDict({"id": "123"}, headers={"x-test": "value"}) - - # Modify dict - response["new_key"] = "new_value" - assert response["new_key"] == "new_value" - - # Update dict - response.update({"another": "field"}) - assert response["another"] == "field" - - # Headers remain intact - assert response.headers["x-test"] == "value" diff --git a/tests/typing_test.py b/tests/typing_test.py deleted file mode 100644 index 2f2fd91..0000000 --- a/tests/typing_test.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Test that ResponseDict is compatible with TypedDict type hints.""" -from typing_extensions import TypedDict - -from resend.response import ResponseDict - - -class EmailResponse(TypedDict): - """Example typed dict like SendResponse.""" - id: str - status: str - - -def test_response_dict_with_typed_dict() -> None: - """Test that ResponseDict works with TypedDict type hints.""" - # Create a ResponseDict - data = {"id": "123", "status": "sent"} - response: EmailResponse = ResponseDict(data, headers={"x-test": "value"}) # type: ignore - - # Type checkers should accept this since ResponseDict is a dict - assert response["id"] == "123" - assert response["status"] == "sent" - - # Headers are accessible - assert isinstance(response, ResponseDict) - if isinstance(response, ResponseDict): - assert response.headers["x-test"] == "value" - - -def returns_typed_dict() -> EmailResponse: - """Function that returns a TypedDict.""" - return ResponseDict({"id": "456", "status": "delivered"}, headers={"x-id": "abc"}) # type: ignore - - -def test_function_return_type() -> None: - """Test that functions can return ResponseDict where TypedDict is expected.""" - result = returns_typed_dict() - - # Works as TypedDict - assert result["id"] == "456" - - # Can check instance and access headers - if isinstance(result, ResponseDict): - assert result.headers["x-id"] == "abc"