Repository: mollie/mollie-api-python

Issue:
Allow setting custom useragent data
See the use of `addVersionString` in https://github.com/mollie/mollie-api-php/blob/master/src/MollieApiClient.php. This will enable integrators to add their implementation details to the User-Agent.

FooBar plugin for Oscar then could do:
```
client = Client()
client.add_useragent_string('Oscar', OSCAR_VERSION)
client.add_useragent_string('FooBar', FOOBAR_VERSION)
```

Discussion:

Patch:
diff --git a/mollie/api/client.py b/mollie/api/client.py
index 79fcc59..281c3b2 100644
--- a/mollie/api/client.py
+++ b/mollie/api/client.py
@@ -3,6 +3,7 @@ import platform
 import re
 import ssl
 import warnings
+from collections import OrderedDict
 
 import requests
 
@@ -33,11 +34,6 @@ class Client(object):
     API_ENDPOINT = 'https://api.mollie.com'
     API_VERSION = 'v2'
     UNAME = ' '.join(platform.uname())
-    USER_AGENT = ' '.join(vs.replace(r'\s+', '-') for vs in [
-        'Mollie/{client_version}'.format(client_version=CLIENT_VERSION),
-        'Python/{python_version}'.format(python_version=platform.python_version()),
-        'OpenSSL/{ssl_version}'.format(ssl_version=ssl.OPENSSL_VERSION.split(' ')[1]),
-    ])
 
     @staticmethod
     def validate_api_endpoint(api_endpoint):
@@ -63,8 +59,10 @@ class Client(object):
     def __init__(self, api_key=None, api_endpoint=None, timeout=10):
         self.api_endpoint = self.validate_api_endpoint(api_endpoint or self.API_ENDPOINT)
         self.api_version = self.API_VERSION
-        self.api_key = None
         self.timeout = timeout
+        self.api_key = None
+
+        # add endpoint resources
         self.payments = Payments(self)
         self.payment_refunds = PaymentRefunds(self)
         self.payment_chargebacks = PaymentChargebacks(self)
@@ -78,6 +76,12 @@ class Client(object):
         self.orders = Orders(self)
         self.subscription_payments = SubscriptionPayments(self)
 
+        # compose base user agent string
+        self.user_agent_components = OrderedDict()
+        self.set_user_agent_component('Mollie', self.CLIENT_VERSION)
+        self.set_user_agent_component('Python', platform.python_version())
+        self.set_user_agent_component('OpenSSL', ssl.OPENSSL_VERSION.split(' ')[1])
+
         if api_key:
             # There is no clean way for supporting both API key and access token acceptance and validation
             #  in __init__(). Furthermore the naming of the parameter would be inconsistent.
@@ -101,6 +105,26 @@ class Client(object):
     def set_timeout(self, timeout):
         self.timeout = timeout
 
+    def set_user_agent_component(self, key, value):
+        """Add or replace new user-agent component strings.
+
+        Given strings are formatted along the format agreed upon by Mollie and implementers:
+        - key and values are separated by a forward slash ("/").
+        - multiple key/values are separated by a space.
+        - keys are camel-cased, and cannot contain spaces.
+        - values cannot contain spaces.
+        """
+        key = ''.join(_x.capitalize() for _x in re.findall(r'\S+', key))
+        if re.search(r'\s+', value):
+            value = '_'.join(re.findall(r'\S+', value))
+        self.user_agent_components[key] = value
+
+    @property
+    def user_agent(self):
+        """Return the formatted user agent string."""
+        components = ["/".join(x) for x in self.user_agent_components.items()]
+        return " ".join(components)
+
     def perform_http_call(self, http_method, path, data=None, params=None):
         if not self.api_key:
             raise RequestSetupError('You have not set an API key. Please use set_api_key() to set the API key.')
@@ -128,7 +152,7 @@ class Client(object):
                     'Accept': 'application/json',
                     'Authorization': 'Bearer {api_key}'.format(api_key=self.api_key),
                     'Content-Type': 'application/json',
-                    'User-Agent': self.USER_AGENT,
+                    'User-Agent': self.user_agent,
                     'X-Mollie-Client-Info': self.UNAME,
                 },
                 params=params,


Test patch:
diff --git a/tests/test_client.py b/tests/test_client.py
index 028094e..516a873 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -1,6 +1,7 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
+import re
 import sys
 from datetime import datetime
 
@@ -313,3 +314,82 @@ def test_client_data_consistency_error(client, response):
     # Update an nonexistent order line. This raises an data consistency error.
     with pytest.raises(DataConsistencyError, match=r'Line id .* not found in response.'):
         order.update_line(line_id, data)
+
+
+def test_client_default_user_agent(client, response):
+    """Default user-agent should contain some known values."""
+
+    regex = re.compile(r'^Mollie/[\d\.]+ Python/[\d\.]+ Openssl/\w+')
+    assert re.match(regex, client.user_agent)
+
+    # perform a request and inpect the actual used headers
+    response.get('https://api.mollie.com/v2/methods', 'methods_list')
+    client.methods.list()
+    request = response.calls[0].request
+    assert re.match(regex, request.headers['User-Agent'])
+
+
+def test_client_set_user_agent_component(response):
+    """We should be able to add useragent components.
+
+    Note: we don't use the fixture client because it is shared between tests, and we don't want it
+    to be clobbered with random User-Agent strings.
+    """
+    client = Client()
+    assert 'Hoeba' not in client.user_agent
+    client.set_user_agent_component('Hoeba', '1.0.0')
+    assert 'Hoeba/1.0.0' in client.user_agent
+
+    response.get('https://api.mollie.com/v2/methods', 'methods_list')
+    client.set_api_key('test_123')
+    client.methods.list()
+    request = response.calls[0].request
+    assert 'Hoeba/1.0.0' in request.headers['User-Agent']
+
+
+@pytest.mark.parametrize("key, expected", [
+    ('lowercase', 'Lowercase'),
+    ('UPPERCASE', 'Uppercase'),
+    ('multiple words', 'MultipleWords'),
+    ('multiple   spaces', 'MultipleSpaces'),
+    ('trailing space ', 'TrailingSpace')
+])
+def test_client_set_user_agent_component_correct_key_syntax(key, expected):
+    """When we receive UA component keys that don't adhere to the proposed syntax, they are corrected."""
+    client = Client()
+    client.set_user_agent_component(key, '1.0.0')
+    assert "{expected}/1.0.0".format(expected=expected) in client.user_agent
+
+
+@pytest.mark.parametrize("value, expected", [
+    ('1.2.3', '1.2.3'),
+    ('singleword', 'singleword'),
+    ('MiXedCaSe', 'MiXedCaSe'),  # should be preserved
+    ('UPPERCASE', 'UPPERCASE'),  # should be preserved
+    ('with space', 'with_space'),
+    ('multiple   spaces', 'multiple_spaces'),
+    ('trailing space ', 'trailing_space')
+])
+def test_client_set_user_agent_component_correct_value_syntax(value, expected):
+    """When we receive UA component values that don't adhere to the proposed syntax, they are corrected."""
+    client = Client()
+    client.set_user_agent_component('Something', value)
+    assert "Something/{expected}".format(expected=expected) in client.user_agent
+
+
+def test_client_update_user_agent_component():
+    """We should be able to update the User-Agent component when using the same key."""
+    client = Client()
+    client.set_user_agent_component('Test', '1.0.0')
+    assert 'Test/1.0.0' in client.user_agent
+
+    # now update the component using the same key
+    client.set_user_agent_component('Test', '2.0.0')
+    assert 'Test/2.0.0' in client.user_agent
+    assert 'Test/1.0.0' not in client.user_agent
+
+    # and update with a key that will be converted to the same value
+    client.set_user_agent_component('TEST', '3.0.0')
+    assert 'Test/3.0.0' in client.user_agent
+    assert 'Test/2.0.0' not in client.user_agent
+    assert 'Test/1.0.0' not in client.user_agent