From 05f11db4ce62b83451bc85ba96a3375f5f2c2273 Mon Sep 17 00:00:00 2001 From: medmunds Date: Fri, 6 Apr 2018 12:57:39 -0700 Subject: [PATCH] SparkPost: add SPARKPOST_API_URL setting to allow SparkPost EU, etc. Closes #100 --- anymail/backends/sparkpost.py | 42 ++++++++++++++++++++++++++++++++- docs/esps/sparkpost.rst | 24 +++++++++++++++++++ tests/test_sparkpost_backend.py | 21 +++++++++++++++-- 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/anymail/backends/sparkpost.py b/anymail/backends/sparkpost.py index a431e1a..e51b18a 100644 --- a/anymail/backends/sparkpost.py +++ b/anymail/backends/sparkpost.py @@ -24,8 +24,18 @@ class EmailBackend(AnymailBaseBackend): # SPARKPOST_API_KEY is optional - library reads from env by default self.api_key = get_anymail_setting('api_key', esp_name=self.esp_name, kwargs=kwargs, allow_bare=True, default=None) + + # SPARKPOST_API_URL is optional - default is set by library; + # if provided, must be a full SparkPost API endpoint, including "/v1" if appropriate + api_url = get_anymail_setting('api_url', esp_name=self.esp_name, kwargs=kwargs, default=None) + extra_sparkpost_params = {} + if api_url is not None: + if api_url.endswith("/"): + api_url = api_url[:-1] + extra_sparkpost_params['base_uri'] = _FullSparkPostEndpoint(api_url) + try: - self.sp = SparkPost(self.api_key) # SparkPost API instance + self.sp = SparkPost(self.api_key, **extra_sparkpost_params) # SparkPost API instance except SparkPostException as err: # This is almost certainly a missing API key raise AnymailConfigurationError( @@ -209,3 +219,33 @@ class SparkPostPayload(BasePayload): # ESP-specific payload construction def set_esp_extra(self, extra): self.params.update(extra) + + +class _FullSparkPostEndpoint(str): + """A string-like object that allows using a complete SparkPost API endpoint url as base_uri: + + sp = SparkPost(api_key, base_uri=_FullSparkPostEndpoint('https://api.sparkpost.com/api/labs')) + + Works around SparkPost.__init__ code `self.base_uri = base_uri + '/api/v' + version`, + which makes it difficult to simply copy and paste full API endpoints from SparkPost's docs + (https://developers.sparkpost.com/api/index.html#header-api-endpoints) -- and completely + prevents using the labs API endpoint (which has no "v" in it). + + Should work with all python-sparkpost releases through at least v1.3.6. + """ + _expect = ['/api/v', '1'] # ignore attempts to concatenate these with me (in order) + + def __add__(self, other): + expected = self._expect[0] + self._expect = self._expect[1:] # (makes a copy for this instance) + if other == expected: + # ignore this operation + if self._expect: + return self + else: + return str(self) # my work is done; just be a normal str now + else: + # something changed in python-sparkpost; please open an Anymail issue to fix + raise ValueError( + "This version of Anymail is not compatible with this version of python-sparkpost.\n" + "(_FullSparkPostEndpoint(%r) expected %r but got %r)" % (self, expected, other)) diff --git a/docs/esps/sparkpost.rst b/docs/esps/sparkpost.rst index 694846b..dc6febe 100644 --- a/docs/esps/sparkpost.rst +++ b/docs/esps/sparkpost.rst @@ -61,6 +61,30 @@ nor ``ANYMAIL_SPARKPOST_API_KEY`` is set. .. _SparkPost account API keys: https://app.sparkpost.com/account/credentials +.. setting:: ANYMAIL_SPARKPOST_API_URL + +.. rubric:: SPARKPOST_API_URL + +The `SparkPost API Endpoint`_ to use. This setting is optional; if not provided, Anymail will +use the :pypi:`python-sparkpost` client default endpoint (``"https://api.sparkpost.com/api/v1"``). + +Set this to use a SparkPost EU account, or to work with any other API endpoint including +SparkPost Enterprise API and SparkPost Labs. + + .. code-block:: python + + ANYMAIL = { + ... + "SPARKPOST_API_URL": "https://api.eu.sparkpost.com/api/v1", # use SparkPost EU + } + +You must specify the full, versioned API endpoint as shown above (not just the base_uri). +This setting only affects Anymail's calls to SparkPost, and will not apply to other code +using :pypi:`python-sparkpost`. + +.. _SparkPost API Endpoint: https://developers.sparkpost.com/api/index.html#header-api-endpoints + + .. _sparkpost-esp-extra: esp_extra support diff --git a/tests/test_sparkpost_backend.py b/tests/test_sparkpost_backend.py index 2a5f394..c3e0e59 100644 --- a/tests/test_sparkpost_backend.py +++ b/tests/test_sparkpost_backend.py @@ -586,8 +586,8 @@ class SparkPostBackendRecipientsRefusedTests(SparkPostBackendMockAPITestCase): @override_settings(EMAIL_BACKEND="anymail.backends.sparkpost.EmailBackend") -class SparkPostBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin): - """Test ESP backend without required settings in place""" +class SparkPostBackendConfigurationTests(SimpleTestCase, AnymailTestMixin): + """Test various SparkPost client options""" def test_missing_api_key(self): with self.assertRaises(AnymailConfigurationError) as cm: @@ -606,3 +606,20 @@ class SparkPostBackendImproperlyConfiguredTests(SimpleTestCase, AnymailTestMixin # Poke into implementation details to verify: self.assertIsNone(conn.api_key) # Anymail prop self.assertEqual(conn.sp.api_key, 'key_from_environment') # SparkPost prop + + @override_settings(ANYMAIL={ + "SPARKPOST_API_URL": "https://api.eu.sparkpost.com/api/v1", + "SPARKPOST_API_KEY": "example-key", + }) + def test_sparkpost_api_url(self): + conn = mail.get_connection() # this init's the backend without sending anything + # Poke into implementation details to verify: + self.assertEqual(conn.sp.base_uri, "https://api.eu.sparkpost.com/api/v1") + + # can also override on individual connection (and even use non-versioned labs endpoint) + conn2 = mail.get_connection(api_url="https://api.sparkpost.com/api/labs") + self.assertEqual(conn2.sp.base_uri, "https://api.sparkpost.com/api/labs") + + # double-check _FullSparkPostEndpoint won't interfere with additional str ops + self.assertEqual(conn.sp.base_uri + "/transmissions/send", + "https://api.eu.sparkpost.com/api/v1/transmissions/send")