Repository: ResonantGeoData/ResonantGeoData

Issue:
Python client issues
- trailing `/` on `api_url` causes failure
- `create_rgd_client` silently failing if `api_url` is bad

@mvandenburgh, would you please look into these


Discussion:
@mvandenburgh, any update?

Here is a demo of the issue:

```py
>>> test = create_rgd_client(username='bane.sullivan@kitware.com', api_url='http://foo.com/bar') # This should error
>>> test.rgd.search()
---------------------------------------------------------------------------
HTTPError                                 Traceback (most recent call last)
<ipython-input-7-92fa0ba0b016> in <module>
----> 1 test.rgd.search()

~/Software/ResonantGeoData/ResonantGeoData/django-rgd/client/rgd_client/plugin.py in search(self, query, predicate, relates, distance, acquired, instrumentation, limit, offset, time_of_day)
     67         )
     68 
---> 69         r = self.session.get('search', params=params)
     70 
     71         r.raise_for_status()

~/anaconda3/envs/django/lib/python3.8/site-packages/requests/sessions.py in get(self, url, **kwargs)
    553 
    554         kwargs.setdefault('allow_redirects', True)
--> 555         return self.request('GET', url, **kwargs)
    556 
    557     def options(self, url, **kwargs):

~/anaconda3/envs/django/lib/python3.8/site-packages/requests_toolbelt/sessions.py in request(self, method, url, *args, **kwargs)
     62         """Send the request after generating the complete URL."""
     63         url = self.create_url(url)
---> 64         return super(BaseUrlSession, self).request(
     65             method, url, *args, **kwargs
     66         )

~/anaconda3/envs/django/lib/python3.8/site-packages/requests/sessions.py in request(self, method, url, params, data, headers, cookies, files, auth, timeout, allow_redirects, proxies, hooks, stream, verify, cert, json)
    540         }
    541         send_kwargs.update(settings)
--> 542         resp = self.send(prep, **send_kwargs)
    543 
    544         return resp

~/anaconda3/envs/django/lib/python3.8/site-packages/requests/sessions.py in send(self, request, **kwargs)
    675             # Redirect resolving generator.
    676             gen = self.resolve_redirects(r, request, **kwargs)
--> 677             history = [resp for resp in gen]
    678         else:
    679             history = []

~/anaconda3/envs/django/lib/python3.8/site-packages/requests/sessions.py in <listcomp>(.0)
    675             # Redirect resolving generator.
    676             gen = self.resolve_redirects(r, request, **kwargs)
--> 677             history = [resp for resp in gen]
    678         else:
    679             history = []

~/anaconda3/envs/django/lib/python3.8/site-packages/requests/sessions.py in resolve_redirects(self, resp, req, stream, timeout, verify, cert, proxies, yield_requests, **adapter_kwargs)
    235             else:
    236 
--> 237                 resp = self.send(
    238                     req,
    239                     stream=stream,

~/anaconda3/envs/django/lib/python3.8/site-packages/requests/sessions.py in send(self, request, **kwargs)
    660 
    661         # Response manipulation hooks
--> 662         r = dispatch_hook('response', hooks, r, **kwargs)
    663 
    664         # Persist cookies

~/anaconda3/envs/django/lib/python3.8/site-packages/requests/hooks.py in dispatch_hook(key, hooks, hook_data, **kwargs)
     29             hooks = [hooks]
     30         for hook in hooks:
---> 31             _hook_data = hook(hook_data, **kwargs)
     32             if _hook_data is not None:
     33                 hook_data = _hook_data

~/Software/ResonantGeoData/ResonantGeoData/django-rgd/client/rgd_client/session.py in assert_status_hook(response, *args, **kwargs)
     46         # Define hook to assert that a request succeeded
     47         def assert_status_hook(response: Response, *args, **kwargs):
---> 48             response.raise_for_status()
     49 
     50         # Response field is present by default

~/anaconda3/envs/django/lib/python3.8/site-packages/requests/models.py in raise_for_status(self)
    951 
    952         if http_error_msg:
--> 953             raise HTTPError(http_error_msg, response=self)
    954 
    955     def close(self):

HTTPError: 404 Client Error: Not Found for url: http://www.foo.com/bar/rgd/search
```


Resolving patch:
diff --git a/django-rgd/client/rgd_client/client.py b/django-rgd/client/rgd_client/client.py
--- a/django-rgd/client/rgd_client/client.py
+++ b/django-rgd/client/rgd_client/client.py
@@ -74,16 +74,16 @@ def _read_api_key(api_url: str, username: str = None, password: str = None) -> O
     This function checks for an environment variable named RGD_API_TOKEN and returns it if it exists.
     If it does not exist, it looks for a file located at ~/.rgd/token and returns its contents.
     """
-    token = os.getenv('RGD_API_TOKEN', None)
-    if token is not None:
-        return token
-
-    try:
-        # read the first line of the text file at ~/.rgd/token
-        with open(API_KEY_DIR_PATH / API_KEY_FILE_NAME, 'r') as fd:
-            api_key = fd.readline().strip()
-    except FileNotFoundError:
-        return None
+    api_key = os.getenv('RGD_API_TOKEN', None)
+    save = False
+    if api_key is None:
+        try:
+            # read the first line of the text file at ~/.rgd/token
+            with open(API_KEY_DIR_PATH / API_KEY_FILE_NAME, 'r') as fd:
+                api_key = fd.readline().strip()
+                save = True  # save any new api key to disk
+        except FileNotFoundError:
+            return None
 
     # Make sure API key works by hitting a protected endpoint
     resp = requests.get(f'{api_url}/rgd/collection', headers={'Authorization': f'Token {api_key}'})
@@ -92,7 +92,10 @@ def _read_api_key(api_url: str, username: str = None, password: str = None) -> O
     if resp.status_code == 401:
         logger.error('API key is invalid.')
         # If username + password were provided, try to get a new API key with them
-        if username is not None and password is not None:
+        # Note we only do this if `save` is `True`, i.e. if the user originally attempted to
+        # instantiate the client with an API key located on disk. If they instead provided an env
+        # var, do not assume that they want a key saved and do not attempt to fetch a new one.
+        if save and username is not None and password is not None:
             logger.info('Attempting to fetch a new API key...')
             api_key = _get_api_key(api_url, username, password, save=True)
             if api_key is not None:
@@ -102,6 +105,9 @@ def _read_api_key(api_url: str, username: str = None, password: str = None) -> O
             logger.error('Provide your username and password next time to fetch a new one.')
             return None
 
+    # If the response failed with an error status other than 401, raise an exception
+    resp.raise_for_status()
+
     return api_key
 
 
@@ -115,6 +121,10 @@ def create_rgd_client(
     # Avoid circular import
     from ._plugin_utils import _inject_plugin_deps, _plugin_classes, _plugin_instances
 
+    # Strip trailing slash
+    if api_url.endswith('/'):
+        api_url = api_url.rstrip('/')
+
     # Create initial client
     client = RgdClient(api_url, username, password, save)
 


Test patch:
diff --git a/django-rgd/tests/test_client.py b/django-rgd/tests/test_client.py
--- a/django-rgd/tests/test_client.py
+++ b/django-rgd/tests/test_client.py
@@ -1,6 +1,7 @@
 import json
 
 import pytest
+from requests.exceptions import HTTPError
 from rgd.models import ChecksumFile, Collection
 from rgd_client import RgdClient, create_rgd_client
 from rgd_client.utils import API_KEY_DIR_PATH, API_KEY_FILE_NAME
@@ -80,3 +81,62 @@ def test_invalid_local_api_key(live_server, user_with_api_key):
 
     # Ensure that `create_rgd_client` overwrote the invalid key with a fresh one
     assert (API_KEY_DIR_PATH / API_KEY_FILE_NAME).read_text() == api_token
+
+
+@pytest.mark.django_db(transaction=True)
+def test_invalid_env_var_api_key(live_server, user_with_api_key, monkeypatch):
+    """Test that a new API key is *not* fetched when one provided via env var is invalid."""
+    username, password, api_token = user_with_api_key
+    monkeypatch.setenv('RGD_API_TOKEN', api_token)
+
+    # Make sure ~/.rgd/token doesn't exist
+    (API_KEY_DIR_PATH / API_KEY_FILE_NAME).unlink(missing_ok=True)
+    assert not (API_KEY_DIR_PATH / API_KEY_FILE_NAME).exists()
+
+    create_rgd_client(
+        username=username,
+        password=password,
+        api_url=f'{live_server.url}/api',
+        save=True,  # save the API key
+    )
+
+    # Make sure ~/.rgd/token wasn't created
+    assert not (API_KEY_DIR_PATH / API_KEY_FILE_NAME).exists()
+
+
+@pytest.mark.django_db(transaction=True)
+def test_api_url_trailing_slash(live_server, user_with_api_key, checksum_file_url: ChecksumFile):
+    url: str = live_server.url
+
+    # Ensure url doesn't end with a trailing slash already
+    if url.endswith('/'):
+        url = url.rstrip('/')
+
+    username, password, _ = user_with_api_key
+
+    py_client = create_rgd_client(
+        username=username,
+        password=password,
+        api_url=f'{url}/api/',  # append a trailing slash to url
+    )
+    assert py_client.session.base_url == f'{url}/api/'
+
+    # Make sure the client behaves as expected given a trailing slash
+    file_dict = py_client.rgd.create_file_from_url(checksum_file_url.get_url())
+    assert file_dict['url'] == checksum_file_url.get_url()
+
+
+@pytest.mark.django_db(transaction=True)
+def test_invalid_api_url(live_server, user_with_api_key, monkeypatch):
+    """Test that the client fails loudly when given an invalid API URL."""
+    url: str = live_server.url
+    username, password, api_key = user_with_api_key
+
+    monkeypatch.setenv('RGD_API_TOKEN', api_key)
+
+    with pytest.raises(HTTPError):
+        create_rgd_client(
+            username=username,
+            password=password,
+            api_url=f'{url}/invalid',
+        )