diff --git a/Dockerfile b/Dockerfile index 583ae9b..c92c98b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,8 @@ ADD requirements.txt requirements.txt RUN pip install -r requirements.txt RUN pip install tox +RUN pip install setuptools==68.2.2 +RUN pip install wheel ENV APP_HOME /app diff --git a/examples/with_custom_http_client.py b/examples/with_custom_http_client.py new file mode 100644 index 0000000..c18d121 --- /dev/null +++ b/examples/with_custom_http_client.py @@ -0,0 +1,62 @@ +import os +from typing import Any, Dict, List, Mapping, Optional, Tuple, Union + +import requests + +import resend +from resend.http_client import HTTPClient + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + + +# Define a custom HTTP client using the requests library with a higher timeout val +class CustomRequestsClient(HTTPClient): + def __init__(self, timeout: int = 300): + self.timeout = timeout + + def request( + self, + method: str, + url: str, + headers: Mapping[str, str], + json: Optional[Union[Dict[str, Any], List[Any]]] = None, + ) -> Tuple[bytes, int, Dict[str, str]]: + print(f"[HTTP] {method.upper()} {url} with timeout={self.timeout}") + try: + response = requests.request( + method=method, + url=url, + headers=headers, + json=json, + timeout=self.timeout, + ) + return ( + response.content, + response.status_code, + dict(response.headers), + ) + except requests.RequestException as e: + raise RuntimeError(f"HTTP request failed: {e}") from e + + +# use the custom HTTP client with a longer timeout +resend.default_http_client = CustomRequestsClient(timeout=400) + +params: resend.Emails.SendParams = { + "from": "onboarding@resend.dev", + "to": ["delivered@resend.dev"], + "subject": "hi", + "html": "hello, world!", + "reply_to": "to@gmail.com", + "bcc": "delivered@resend.dev", + "cc": ["delivered@resend.dev"], + "tags": [ + {"name": "tag1", "value": "tagvalue1"}, + {"name": "tag2", "value": "tagvalue2"}, + ], +} + + +email: resend.Email = resend.Emails.send(params) +print(f"{email}") diff --git a/resend/__init__.py b/resend/__init__.py index 773817f..b1fcb7e 100644 --- a/resend/__init__.py +++ b/resend/__init__.py @@ -15,6 +15,8 @@ from .emails._email import Email from .emails._emails import Emails from .emails._tag import Tag +from .http_client import HTTPClient +from .http_client_requests import RequestsClient from .request import Request from .version import __version__, get_version @@ -22,6 +24,9 @@ api_key = os.environ.get("RESEND_API_KEY") api_url = os.environ.get("RESEND_API_URL", "https://api.resend.com") +# HTTP Client +default_http_client: HTTPClient = RequestsClient() + # API resources from .emails._emails import Emails # noqa @@ -45,4 +50,6 @@ "Attachment", "Tag", "Broadcast", + # Default HTTP Client + "RequestsClient", ] diff --git a/resend/exceptions.py b/resend/exceptions.py index 92dcf7c..7afffb0 100644 --- a/resend/exceptions.py +++ b/resend/exceptions.py @@ -4,7 +4,7 @@ codes as outlined in https://resend.com/docs/api-reference/errors. """ -from typing import Any, Dict, Union +from typing import Any, Dict, NoReturn, Union class ResendError(Exception): @@ -175,7 +175,7 @@ def __init__( def raise_for_code_and_type( code: Union[str, int], error_type: str, message: str -) -> None: +) -> NoReturn: """Raise the appropriate error based on the code and type. Args: diff --git a/resend/http_client.py b/resend/http_client.py new file mode 100644 index 0000000..0863420 --- /dev/null +++ b/resend/http_client.py @@ -0,0 +1,20 @@ +from abc import ABC, abstractmethod +from typing import Dict, List, Mapping, Optional, Tuple, Union + + +class HTTPClient(ABC): + """ + Abstract base class for HTTP clients. + This class defines the interface for making HTTP requests. + Subclasses should implement the `request` method. + """ + + @abstractmethod + def request( + self, + method: str, + url: str, + headers: Mapping[str, str], + json: Optional[Union[Dict[str, object], List[object]]] = None, + ) -> Tuple[bytes, int, Mapping[str, str]]: + pass diff --git a/resend/http_client_requests.py b/resend/http_client_requests.py new file mode 100644 index 0000000..6c78a2c --- /dev/null +++ b/resend/http_client_requests.py @@ -0,0 +1,35 @@ +from typing import Dict, List, Mapping, Optional, Tuple, Union + +import requests + +from resend.http_client import HTTPClient + + +class RequestsClient(HTTPClient): + """ + This is the default HTTP client implementation using the requests library. + """ + + def __init__(self, timeout: int = 30): + self._timeout = timeout + + def request( + self, + method: str, + url: str, + headers: Mapping[str, str], + json: Optional[Union[Dict[str, object], List[object]]] = None, + ) -> Tuple[bytes, int, Mapping[str, str]]: + try: + resp = requests.request( + method=method, + url=url, + headers=headers, + json=json, + timeout=self._timeout, + ) + return resp.content, resp.status_code, resp.headers + except requests.RequestException as e: + # This gets caught by the request.perform() method + # and raises a ResendError with the error type "HttpClientError" + raise RuntimeError(f"Request failed: {e}") from e diff --git a/resend/request.py b/resend/request.py index f0dfaa9..623dd25 100644 --- a/resend/request.py +++ b/resend/request.py @@ -1,23 +1,25 @@ +import json from typing import Any, Dict, Generic, List, Optional, Union, cast -import requests from typing_extensions import Literal, TypeVar import resend -from resend.exceptions import NoContentError, raise_for_code_and_type +from resend.exceptions import (NoContentError, ResendError, + raise_for_code_and_type) from resend.version import get_version RequestVerb = Literal["get", "post", "put", "patch", "delete"] - T = TypeVar("T") +ParamsType = Union[Dict[str, Any], List[Dict[str, Any]]] +HeadersType = Dict[str, str] + -# This class wraps the HTTP request creation logic class Request(Generic[T]): def __init__( self, path: str, - params: Union[Dict[Any, Any], List[Dict[Any, Any]]], + params: ParamsType, verb: RequestVerb, options: Optional[Dict[str, Any]] = None, ): @@ -27,94 +29,80 @@ def __init__( self.options = options def perform(self) -> Union[T, None]: - """Is the main function that makes the HTTP request - to the Resend API. It uses the path, params, and verb attributes - to make the request. - - Returns: - Union[T, None]: A generic type of the Request class or None - - Raises: - requests.HTTPError: If the request fails - """ - resp = self.make_request(url=f"{resend.api_url}{self.path}") + data = self.make_request(url=f"{resend.api_url}{self.path}") - # delete calls do not return a body - if resp.text == "" and resp.status_code == 200: - return None - - # this is a safety net, if we get here it means the Resend API is having issues - # and most likely the gateway is returning htmls - if "application/json" not in resp.headers["content-type"]: + if isinstance(data, dict) and data.get("statusCode") not in (None, 200): raise_for_code_and_type( - code=500, - message="Failed to parse Resend API response. Please try again.", - error_type="InternalServerError", + code=data.get("statusCode") or 500, + message=data.get("message", "Unknown error"), + error_type=data.get("name", "InternalServerError"), ) - # handle error in case there is a statusCode attr present - # and status != 200 and response is a json. - if resp.status_code != 200 and resp.json().get("statusCode"): - error = resp.json() - raise_for_code_and_type( - code=error.get("statusCode"), - message=error.get("message"), - error_type=error.get("name"), - ) - return cast(T, resp.json()) + return cast(T, data) def perform_with_content(self) -> T: - """ - Perform an HTTP request and return the response content. - - Returns: - T: The content of the response - - Raises: - NoContentError: If the response content is `None`. - """ resp = self.perform() if resp is None: raise NoContentError() return resp - def __get_headers(self) -> Dict[Any, Any]: - """get_headers returns the HTTP headers that will be - used for every req. - - Returns: - Dict: configured HTTP Headers - """ - headers = { + def __get_headers(self) -> HeadersType: + headers: HeadersType = { "Accept": "application/json", "Authorization": f"Bearer {resend.api_key}", "User-Agent": f"resend-python:{get_version()}", } - # Add the Idempotency-Key header if the verb is POST - # and the options dict contains the key - if self.verb == "post" and (self.options and "idempotency_key" in self.options): - headers["Idempotency-Key"] = self.options["idempotency_key"] + if self.verb == "post" and self.options and "idempotency_key" in self.options: + headers["Idempotency-Key"] = str(self.options["idempotency_key"]) + return headers - def make_request(self, url: str) -> requests.Response: - """make_request is a helper function that makes the actual - HTTP request to the Resend API. + def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]: + headers = self.__get_headers() - Args: - url (str): The URL to make the request to + if isinstance(self.params, dict): + json_params: Optional[Union[Dict[str, Any], List[Any]]] = { + str(k): v for k, v in self.params.items() + } + elif isinstance(self.params, list): + json_params = [dict(item) for item in self.params] + else: + json_params = None - Returns: - requests.Response: The response object from the request + try: + content, _status_code, resp_headers = resend.default_http_client.request( + method=self.verb, + url=url, + headers=headers, + json=json_params, + ) - Raises: - requests.HTTPError: If the request fails - """ - headers = self.__get_headers() - params = self.params - verb = self.verb + # Safety net around the HTTP Client + except Exception as e: + raise ResendError( + code=500, + message=str(e), + error_type="HttpClientError", + suggested_action="Request failed, please try again.", + ) + + content_type = {k.lower(): v for k, v in resp_headers.items()}.get( + "content-type", "" + ) + + if "application/json" not in content_type: + raise_for_code_and_type( + code=500, + message=f"Expected JSON response but got: {content_type}", + error_type="InternalServerError", + ) try: - return requests.request(verb, url, json=params, headers=headers) - except requests.HTTPError as e: - raise e + return cast(Union[Dict[str, Any], List[Any]], json.loads(content)) + except json.JSONDecodeError: + raise_for_code_and_type( + code=500, + message="Failed to decode JSON response", + error_type="InternalServerError", + ) diff --git a/tests/conftest.py b/tests/conftest.py index 0684e6d..72b8bc3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,25 +10,20 @@ class ResendBaseTest(TestCase): def setUp(self) -> None: resend.api_key = "re_123" - - self.patcher = patch("resend.Request.make_request") + resend.default_http_client = resend.RequestsClient() + self.patcher = patch("resend.request.Request.make_request") self.mock = self.patcher.start() - self.m = MagicMock( - status_code=200, - headers={"content-type": "application/json; charset=utf-8"}, - ) - self.mock.return_value = self.m def tearDown(self) -> None: self.patcher.stop() def set_mock_json(self, mock_json: Any) -> None: """Auxiliary function to set the mock json return value""" - self.m.json = lambda: mock_json + self.mock.return_value = mock_json def set_mock_text(self, mock_text: str) -> None: """Auxiliary function to set the mock text return value""" - self.m.text = mock_text + self.mock.text = mock_text def set_magic_mock_obj(self, magic_mock_obj: MagicMock) -> None: """Auxiliary function to set the mock object""" diff --git a/tests/default_http_client_test.py b/tests/default_http_client_test.py new file mode 100644 index 0000000..95007be --- /dev/null +++ b/tests/default_http_client_test.py @@ -0,0 +1,64 @@ +from typing import Any, Dict, Mapping, Tuple, cast +from unittest import TestCase +from unittest.mock import create_autospec + +import pytest + +import resend +import resend.exceptions +from resend.http_client import HTTPClient + + +class TestDefaultHttpClientUsage(TestCase): + def setUp(self) -> None: + resend.api_key = "re_test" + resend.default_http_client = cast(HTTPClient, None) + + def tearDown(self) -> None: + resend.api_key = None + resend.default_http_client = cast(HTTPClient, None) + + def test_default_http_client_called_with_correct_payload(self) -> None: + mock_client = create_autospec(HTTPClient, instance=True) + mock_client.name = "mock" + mock_client.request.return_value = ( + b'{"id": "email_123"}', + 200, + {"Content-Type": "application/json"}, + ) + + resend.default_http_client = mock_client + + resend.Emails.send( + { + "from": "hello@example.com", + "to": ["world@example.com"], + "subject": "Hi!", + "html": "hi", + } + ) + + mock_client.request.assert_called_once() + args, kwargs = mock_client.request.call_args + assert kwargs["method"] == "post" + assert "/emails" in kwargs["url"] + assert kwargs["json"]["subject"] == "Hi!" + + def test_perform_raises_resend_error_on_runtime_error(self) -> None: + class RaisesClient(resend.http_client.HTTPClient): + def request( + self, *args: object, **kwargs: object + ) -> Tuple[bytes, int, Mapping[str, str]]: + raise RuntimeError("Connection broken") + + resend.default_http_client = RaisesClient() + + request: resend.Request[Dict[str, Any]] = resend.Request( + path="/emails", params={}, verb="post" + ) + + with pytest.raises(resend.exceptions.ResendError) as exc: + request.perform() + + assert "Connection broken" in str(exc.value) + assert exc.value.error_type == "HttpClientError" diff --git a/tests/request_test.py b/tests/request_test.py index b55c2a9..6369b1d 100644 --- a/tests/request_test.py +++ b/tests/request_test.py @@ -7,13 +7,13 @@ class TestResendRequest(unittest.TestCase): - @patch("resend.request.requests.request") + @patch("resend.http_client_requests.requests.request") @patch("resend.api_key", new="test_key") def test_request_idempotency_key_is_set(self, mock_requests: MagicMock) -> None: mock_response = Mock() - mock_response.text = "{}" + mock_response.content = b"{}" mock_response.status_code = 200 - mock_response.headers = {"content-type": "application/json"} + mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {} mock_requests.return_value = mock_response @@ -37,11 +37,11 @@ def test_request_idempotency_key_is_set(self, mock_requests: MagicMock) -> None: self.assertEqual(headers["User-Agent"], f"resend-python:{get_version()}") self.assertEqual(headers["Idempotency-Key"], "abc-123") - @patch("resend.request.requests.request") + @patch("resend.http_client_requests.requests.request") @patch("resend.api_key", new="test_key") def test_request_idempotency_key_is_not_set(self, mock_requests: MagicMock) -> None: mock_response = Mock() - mock_response.text = "{}" + mock_response.content = b"{}" mock_response.status_code = 200 mock_response.headers = {"content-type": "application/json"} mock_response.json.return_value = {}