From 40e2ebcd79f112f8c39419c4083b3ea8379baf76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20H=C3=B6ffner?= Date: Thu, 18 Jul 2024 20:36:15 +0200 Subject: [PATCH 01/12] Simplify isinstance Checks for API Token Strings --- pyDataverse/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyDataverse/api.py b/pyDataverse/api.py index a15decc..2754aa0 100644 --- a/pyDataverse/api.py +++ b/pyDataverse/api.py @@ -69,12 +69,12 @@ def __init__( self.base_url = base_url self.client = None - if not isinstance(api_version, ("".__class__, "".__class__)): + if not isinstance(api_version, str): raise ApiUrlError("api_version {0} is not a string.".format(api_version)) self.api_version = api_version if api_token: - if not isinstance(api_token, ("".__class__, "".__class__)): + if not isinstance(api_token, str): raise ApiAuthorizationError("Api token passed is not a string.") self.api_token = api_token From 02b847af08bd680deff5e007dffa8c922a0bf91d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20H=C3=B6ffner?= Date: Thu, 18 Jul 2024 20:43:11 +0200 Subject: [PATCH 02/12] Unify __str__ Methods for APIs This way, they use the class names and base URLs, instead of being specified for each class. --- pyDataverse/api.py | 59 ++-------------------------------------------- 1 file changed, 2 insertions(+), 57 deletions(-) diff --git a/pyDataverse/api.py b/pyDataverse/api.py index 2754aa0..aa0840b 100644 --- a/pyDataverse/api.py +++ b/pyDataverse/api.py @@ -90,7 +90,7 @@ def __init__( self.timeout = 500 def __str__(self): - """Return name of Api() class for users. + """Return the class name and URL of the used API class. Returns ------- @@ -98,7 +98,7 @@ def __str__(self): Naming of the API class. """ - return "API: {0}".format(self.base_url_api) + return f"{self.__class__.__name__}: {self.base_url_api}" def get_request(self, url, params=None, auth=False): """Make a GET request. @@ -439,17 +439,6 @@ def __init__(self, base_url, api_token=None): else: self.base_url_api_data_access = self.base_url_api - def __str__(self): - """Return name of DataAccessApi() class for users. - - Returns - ------- - str - Naming of the DataAccess API class. - - """ - return "Data Access API: {0}".format(self.base_url_api_data_access) - def get_datafile( self, identifier, @@ -670,17 +659,6 @@ def __init__(self, base_url, api_token=None, api_version="latest"): else: self.base_url_api_metrics = None - def __str__(self): - """Return name of MetricsApi() class for users. - - Returns - ------- - str - Naming of the MetricsApi() class. - - """ - return "Metrics API: {0}".format(self.base_url_api_metrics) - def total(self, data_type, date_str=None, auth=False): """ GET https://$SERVER/api/info/metrics/$type @@ -789,17 +767,6 @@ def __init__(self, base_url: str, api_token=None, api_version="v1"): super().__init__(base_url, api_token, api_version) self.base_url_api_native = self.base_url_api - def __str__(self): - """Return name of NativeApi() class for users. - - Returns - ------- - str - Naming of the NativeApi() class. - - """ - return "Native API: {0}".format(self.base_url_api_native) - def get_dataverse(self, identifier, auth=False): """Get dataverse metadata by alias or id. @@ -2467,17 +2434,6 @@ def __init__(self, base_url, api_token=None, api_version="latest"): else: self.base_url_api_search = self.base_url_api - def __str__(self): - """Return name of SearchApi() class for users. - - Returns - ------- - str - Naming of the Search API class. - - """ - return "Search API: {0}".format(self.base_url_api_search) - def search( self, q_str, @@ -2572,17 +2528,6 @@ def __init__( else: self.base_url_api_sword = base_url - def __str__(self): - """Return name of :class:Api() class for users. - - Returns - ------- - str - Naming of the SWORD API class. - - """ - return "SWORD API: {0}".format(self.base_url_api_sword) - def get_service_document(self): url = "{0}/swordv2/service-document".format(self.base_url_api_sword) return self.get_request(url, auth=True) From 0c6df8a0d27d4e730be93d53351371057c0b3dba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20H=C3=B6ffner?= Date: Fri, 19 Jul 2024 02:46:07 +0200 Subject: [PATCH 03/12] Add pytest-asyncio --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 6c513fa..166356c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ optional = true [tool.poetry.group.tests.dependencies] pytest = "^8.1.1" pytest-cov = "^5.0.0" +pytest-asyncio = "^0.23.7" tox = "^4.14.2" selenium = "^4.19.0" From 068c432d53844bec3d280082f79c71f9289cce6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20H=C3=B6ffner?= Date: Thu, 18 Jul 2024 01:06:25 +0200 Subject: [PATCH 04/12] Add Failing Test: API_TOKEN is Exposed in GET This test verifies that parameter ?key is not used. Otherwise, it will occur in error messages, which might lead to an unwanted disclosure of the key, e.g., when sharing log messages. --- tests/api/test_api.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/api/test_api.py b/tests/api/test_api.py index f882127..c311c16 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -2,7 +2,7 @@ import pytest from httpx import Response from time import sleep -from pyDataverse.api import NativeApi +from pyDataverse.api import DataAccessApi, NativeApi from pyDataverse.exceptions import ApiAuthorizationError from pyDataverse.exceptions import ApiUrlError from pyDataverse.models import Dataset @@ -150,3 +150,11 @@ def test_token_right_create_dataset_rights(self): resp = api_su.delete_dataset(pid) assert resp.json()["status"] == "OK" + + def test_token_should_not_be_exposed_on_error(self): + BASE_URL = os.getenv("BASE_URL") + API_TOKEN = os.getenv("API_TOKEN") + api = DataAccessApi(BASE_URL, API_TOKEN) + + result = api.get_datafile("does-not-exist").json() + assert API_TOKEN not in result["requestUrl"] From 89149bcdaa04ff860af6a6d5d29dc401e46cd9fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20H=C3=B6ffner?= Date: Thu, 18 Jul 2024 20:32:43 +0200 Subject: [PATCH 05/12] Add Custom Auth Implementations - Add custom httpx.Auth implementation for Dataverse API Tokens - Add custom httpx.Auth implementation for Dataverse Bearer Token - Add auth to docs --- pyDataverse/auth.py | 103 ++++++++++++++++++++++++++ pyDataverse/docs/source/reference.rst | 7 ++ tests/auth/__init__.py | 0 tests/auth/test_auth.py | 44 +++++++++++ 4 files changed, 154 insertions(+) create mode 100644 pyDataverse/auth.py create mode 100644 tests/auth/__init__.py create mode 100644 tests/auth/test_auth.py diff --git a/pyDataverse/auth.py b/pyDataverse/auth.py new file mode 100644 index 0000000..9a04b4f --- /dev/null +++ b/pyDataverse/auth.py @@ -0,0 +1,103 @@ +"""This module contains authentication handlers compatible with :class:`httpx.Auth`""" + +from typing import Generator + +from httpx import Auth, Request, Response + +from pyDataverse.exceptions import ApiAuthorizationError + + +class ApiTokenAuth(Auth): + """An authentication handler to add an API token as the X-Dataverse-key + header. + + For more information on how to retrieve an API token and how it is used, + please refer to https://guides.dataverse.org/en/latest/api/auth.html. + """ + + def __init__(self, api_token: str): + """Initializes the auth handler with an API token. + + Parameters + ---------- + api_token : str + The API token retrieved from your dataverse instance user profile. + + Examples + -------- + + >>> import os + >>> from pyDataverse.api import DataAccessApi + >>> base_url = 'https://demo.dataverse.org' + >>> api_token_auth = ApiTokenAuth(os.getenv('API_TOKEN')) + >>> api = DataAccessApi(base_url, api_token_auth) + + """ + if not isinstance(api_token, str): + raise ApiAuthorizationError("Api token passed is not a string.") + self.api_token = api_token + + def auth_flow(self, request: Request) -> Generator[Request, Response, None]: + """Adds the X-Dataverse-key header with the API token and yields the + original :class:`httpx.Request`. + + Parameters + ---------- + request : httpx.Request + The request object which requires authentication headers + + Yields + ------ + httpx.Request + The original request with modified headers + """ + request.headers["X-Dataverse-key"] = self.api_token + yield request + + +class BearerTokenAuth(Auth): + """An authentication handler to add a Bearer token as defined in `RFC 6750 + `_ to the request. + + A bearer token could be obtained from an OIDC provider, for example, + Keycloak. + """ + + def __init__(self, bearer_token: str): + """Initializes the auth handler with a bearer token. + + Parameters + ---------- + bearer_token : str + The bearer token retrieved from your OIDC provider. + + Examples + -------- + + >>> import os + >>> from pyDataverse.api import DataAccessApi + >>> base_url = 'https://demo.dataverse.org' + >>> bearer_token_auth = OAuthBearerTokenAuth(os.getenv('OAUTH_TOKEN')) + >>> api = DataAccessApi(base_url, bearer_token_auth) + + """ + if not isinstance(bearer_token, str): + raise ApiAuthorizationError("Api token passed is not a string.") + self.bearer_token = bearer_token + + def auth_flow(self, request: Request) -> Generator[Request, Response, None]: + """Adds the X-Dataverse-key header with the API token and yields the + original :class:`httpx.Request`. + + Parameters + ---------- + request : httpx.Request + The request object which requires authentication headers + + Yields + ------ + httpx.Request + The original request with modified headers + """ + request.headers["Authorization"] = f"Bearer {self.bearer_token}" + yield request diff --git a/pyDataverse/docs/source/reference.rst b/pyDataverse/docs/source/reference.rst index d4787ef..3368d12 100644 --- a/pyDataverse/docs/source/reference.rst +++ b/pyDataverse/docs/source/reference.rst @@ -38,6 +38,13 @@ Helper functions. :members: +Auth Helpers +----------------------------- + +.. automodule:: pyDataverse.auth + :members: + + Exceptions ----------------------------- diff --git a/tests/auth/__init__.py b/tests/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/auth/test_auth.py b/tests/auth/test_auth.py new file mode 100644 index 0000000..04265bf --- /dev/null +++ b/tests/auth/test_auth.py @@ -0,0 +1,44 @@ +import uuid + +import pytest +from httpx import Request + +from pyDataverse.auth import ApiTokenAuth, BearerTokenAuth +from pyDataverse.exceptions import ApiAuthorizationError + + +class TestApiTokenAuth: + def test_token_header_is_added_during_auth_flow(self): + api_token = str(uuid.uuid4()) + auth = ApiTokenAuth(api_token) + request = Request("GET", "https://example.org") + assert "X-Dataverse-key" not in request.headers + modified_request = next(auth.auth_flow(request)) + assert "X-Dataverse-key" in modified_request.headers + assert modified_request.headers["X-Dataverse-key"] == api_token + + @pytest.mark.parametrize( + "non_str_token", (123, object(), lambda x: x, 1.423, b"123", uuid.uuid4()) + ) + def test_raise_if_token_is_not_str(self, non_str_token): + with pytest.raises(ApiAuthorizationError): + ApiTokenAuth(non_str_token) + + +class TestBearerTokenAuth: + def test_authorization_header_is_added_during_auth_flow(self): + # Token as shown in RFC 6750 + bearer_token = "mF_9.B5f-4.1JqM" + auth = BearerTokenAuth(bearer_token) + request = Request("GET", "https://example.org") + assert "Authorization" not in request.headers + modified_request = next(auth.auth_flow(request)) + assert "Authorization" in modified_request.headers + assert modified_request.headers["Authorization"] == f"Bearer {bearer_token}" + + @pytest.mark.parametrize( + "non_str_token", (123, object(), lambda x: x, 1.423, b"123", uuid.uuid4()) + ) + def test_raise_if_token_is_not_str(self, non_str_token): + with pytest.raises(ApiAuthorizationError): + BearerTokenAuth(non_str_token) From d3a1fd9cbe4af359fabf4941fcddfd335d6cd8cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20H=C3=B6ffner?= Date: Thu, 18 Jul 2024 22:55:04 +0200 Subject: [PATCH 06/12] Prefer httpx.Auth Instead of api_token It is still possible to use the api_token parameter. In this case assume an API token was copied from the user profile of a Dataverse instance. If both are specified, an api_token and an explicit auth method, warn the user and use the auth method. Closes #192. --- pyDataverse/api.py | 130 +++++++++++++++++++------- pyDataverse/docs/source/reference.rst | 1 + tests/api/test_api.py | 31 ++++++ 3 files changed, 129 insertions(+), 33 deletions(-) diff --git a/pyDataverse/api.py b/pyDataverse/api.py index aa0840b..c01b51a 100644 --- a/pyDataverse/api.py +++ b/pyDataverse/api.py @@ -4,9 +4,11 @@ from typing import Any, Dict, Optional import httpx import subprocess as sp +from warnings import warn from httpx import ConnectError, Response +from pyDataverse.auth import ApiTokenAuth from pyDataverse.exceptions import ( ApiAuthorizationError, ApiUrlError, @@ -41,6 +43,8 @@ def __init__( base_url: str, api_token: Optional[str] = None, api_version: str = "latest", + *, + auth: Optional[httpx.Auth] = None, ): """Init an Api() class. @@ -52,16 +56,54 @@ def __init__( base_url : str Base url for Dataverse api. api_token : str | None - Api token for Dataverse api. + API token for Dataverse API. If you provide an :code:`api_token`, we + assume it is an API token as retrieved via your Dataverse instance + user profile. + We recommend using the :code:`auth` argument instead. + To retain the current behaviour with the :code:`auth` argument, change + .. code-block:: python + + Api("https://demo.dataverse.org", "my_token") + + to + + .. code-block:: python + + from pyDataverse.auth import ApiTokenAuth + + Api("https://demo.dataverse.org", auth=ApiTokenAuth("my_token")) + + If you are using an OIDC/OAuth 2.0 Bearer token, please use the :code:`auth` + parameter with the :py:class:`.auth.BearerTokenAuth`. + api_version : str + The version string of the Dataverse API or :code:`latest`, e.g., + :code:`v1`. Defaults to :code:`latest`, which drops the version from + the API urls. + auth : httpx.Auth | None + You can provide any authentication mechanism you like to connect to + your Dataverse instance. The most common mechanisms are implemented + in :py:mod:`.auth`, but if one is missing, you can use your own + `httpx.Auth`-compatible class. For more information, have a look at + `httpx' Authentication docs + `_. Examples -------- Create an Api connection:: + .. code-block:: + >>> from pyDataverse.api import Api >>> base_url = 'http://demo.dataverse.org' >>> api = Api(base_url) + .. code-block:: + + >>> from pyDataverse.api import Api + >>> from pyDataverse.auth import ApiTokenAuth + >>> base_url = 'http://demo.dataverse.org' + >>> api = Api(base_url, ApiTokenAuth('my_api_token')) + """ if not isinstance(base_url, str): raise ApiUrlError("base_url {0} is not a string.".format(base_url)) @@ -73,10 +115,19 @@ def __init__( raise ApiUrlError("api_version {0} is not a string.".format(api_version)) self.api_version = api_version - if api_token: - if not isinstance(api_token, str): - raise ApiAuthorizationError("Api token passed is not a string.") + self.auth = auth self.api_token = api_token + if api_token is not None: + if auth is None: + self.auth = ApiTokenAuth(api_token) + else: + self.api_token = None + warn( + UserWarning( + "You provided both, an api_token and a custom auth " + "method. We will only use the auth method." + ) + ) if self.base_url: if self.api_version == "latest": @@ -119,21 +170,21 @@ def get_request(self, url, params=None, auth=False): Response object of httpx library. """ - params = {} - params["User-Agent"] = "pydataverse" - if self.api_token: - params["key"] = str(self.api_token) + headers = {} + headers["User-Agent"] = "pydataverse" if self.client is None: return self._sync_request( method=httpx.get, url=url, + headers=headers, params=params, ) else: return self._async_request( method=self.client.get, url=url, + headers=headers, params=params, ) @@ -162,10 +213,8 @@ def post_request(self, url, data=None, auth=False, params=None, files=None): Response object of httpx library. """ - params = {} - params["User-Agent"] = "pydataverse" - if self.api_token: - params["key"] = self.api_token + headers = {} + headers["User-Agent"] = "pydataverse" if isinstance(data, str): data = json.loads(data) @@ -175,12 +224,19 @@ def post_request(self, url, data=None, auth=False, params=None, files=None): if self.client is None: return self._sync_request( - method=httpx.post, url=url, params=params, files=files, **request_params + method=httpx.post, + url=url, + json=data, + headers=headers, + params=params, + files=files, ) else: return self._async_request( method=self.client.post, url=url, + json=data, + headers=headers, params=params, files=files, **request_params, @@ -207,10 +263,8 @@ def put_request(self, url, data=None, auth=False, params=None): Response object of httpx library. """ - params = {} - params["User-Agent"] = "pydataverse" - if self.api_token: - params["key"] = self.api_token + headers = {} + headers["User-Agent"] = "pydataverse" if isinstance(data, str): data = json.loads(data) @@ -222,6 +276,8 @@ def put_request(self, url, data=None, auth=False, params=None): return self._sync_request( method=httpx.put, url=url, + json=data, + headers=headers, params=params, **request_params, ) @@ -229,6 +285,8 @@ def put_request(self, url, data=None, auth=False, params=None): return self._async_request( method=self.client.put, url=url, + json=data, + headers=headers, params=params, **request_params, ) @@ -252,21 +310,21 @@ def delete_request(self, url, auth=False, params=None): Response object of httpx library. """ - params = {} - params["User-Agent"] = "pydataverse" - if self.api_token: - params["key"] = self.api_token + headers = {} + headers["User-Agent"] = "pydataverse" if self.client is None: return self._sync_request( method=httpx.delete, url=url, + headers=headers, params=params, ) else: return self._async_request( method=self.client.delete, url=url, + headers=headers, params=params, ) @@ -321,7 +379,7 @@ def _sync_request( kwargs = self._filter_kwargs(kwargs) try: - resp = method(**kwargs, follow_redirects=True, timeout=None) + resp = method(**kwargs, auth=self.auth, follow_redirects=True, timeout=None) if resp.status_code == 401: error_msg = resp.json()["message"] raise ApiAuthorizationError( @@ -364,7 +422,7 @@ async def _async_request( kwargs = self._filter_kwargs(kwargs) try: - resp = await method(**kwargs) + resp = await method(**kwargs, auth=self.auth) if resp.status_code == 401: error_msg = resp.json()["message"] @@ -431,9 +489,9 @@ class DataAccessApi(Api): """ - def __init__(self, base_url, api_token=None): + def __init__(self, base_url, api_token=None, *, auth=None): """Init an DataAccessApi() class.""" - super().__init__(base_url, api_token) + super().__init__(base_url, api_token, auth=auth) if base_url: self.base_url_api_data_access = "{0}/access".format(self.base_url_api) else: @@ -651,9 +709,9 @@ class MetricsApi(Api): """ - def __init__(self, base_url, api_token=None, api_version="latest"): + def __init__(self, base_url, api_token=None, api_version="latest", *, auth=None): """Init an MetricsApi() class.""" - super().__init__(base_url, api_token, api_version) + super().__init__(base_url, api_token, api_version, auth=auth) if base_url: self.base_url_api_metrics = "{0}/api/info/metrics".format(self.base_url) else: @@ -752,7 +810,7 @@ class NativeApi(Api): """ - def __init__(self, base_url: str, api_token=None, api_version="v1"): + def __init__(self, base_url: str, api_token=None, api_version="v1", *, auth=None): """Init an Api() class. Scheme, host and path combined create the base-url for the api. @@ -764,7 +822,7 @@ def __init__(self, base_url: str, api_token=None, api_version="v1"): Api version of Dataverse native api. Default is `v1`. """ - super().__init__(base_url, api_token, api_version) + super().__init__(base_url, api_token, api_version, auth=auth) self.base_url_api_native = self.base_url_api def get_dataverse(self, identifier, auth=False): @@ -2426,9 +2484,9 @@ class SearchApi(Api): """ - def __init__(self, base_url, api_token=None, api_version="latest"): + def __init__(self, base_url, api_token=None, api_version="latest", *, auth=None): """Init an SearchApi() class.""" - super().__init__(base_url, api_token, api_version) + super().__init__(base_url, api_token, api_version, auth=auth) if base_url: self.base_url_api_search = "{0}/search?q=".format(self.base_url_api) else: @@ -2503,7 +2561,13 @@ class SwordApi(Api): """ def __init__( - self, base_url, api_version="v1.1", api_token=None, sword_api_version="v1.1" + self, + base_url, + api_version="v1.1", + api_token=None, + sword_api_version="v1.1", + *, + auth=None, ): """Init a :class:`SwordApi ` instance. @@ -2513,7 +2577,7 @@ def __init__( Api version of Dataverse SWORD API. """ - super().__init__(base_url, api_token, api_version) + super().__init__(base_url, api_token, api_version, auth=auth) if not isinstance(sword_api_version, ("".__class__, "".__class__)): raise ApiUrlError( "sword_api_version {0} is not a string.".format(sword_api_version) diff --git a/pyDataverse/docs/source/reference.rst b/pyDataverse/docs/source/reference.rst index 3368d12..5a76291 100644 --- a/pyDataverse/docs/source/reference.rst +++ b/pyDataverse/docs/source/reference.rst @@ -17,6 +17,7 @@ Access all of Dataverse APIs. .. automodule:: pyDataverse.api :members: + :special-members: Models Interface diff --git a/tests/api/test_api.py b/tests/api/test_api.py index c311c16..2fe0547 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -3,6 +3,7 @@ from httpx import Response from time import sleep from pyDataverse.api import DataAccessApi, NativeApi +from pyDataverse.auth import ApiTokenAuth from pyDataverse.exceptions import ApiAuthorizationError from pyDataverse.exceptions import ApiUrlError from pyDataverse.models import Dataset @@ -34,6 +35,36 @@ def test_api_connect_base_url_wrong(self): NativeApi(None) +class TestApiTokenAndAuthBehavior: + def test_api_token_none_and_auth_none(self): + api = NativeApi("https://demo.dataverse.org") + assert api.api_token is None + assert api.auth is None + + def test_api_token_none_and_auth(self): + auth = ApiTokenAuth("mytoken") + api = NativeApi("https://demo.dataverse.org", auth=auth) + assert api.api_token is None + assert api.auth is auth + + def test_api_token_and_auth(self): + auth = ApiTokenAuth("mytoken") + # Only one, api_token or auth, should be specified + with pytest.warns(UserWarning): + api = NativeApi( + "https://demo.dataverse.org", api_token="sometoken", auth=auth + ) + assert api.api_token is None + assert api.auth is auth + + def test_api_token_and_auth_none(self): + api_token = "mytoken" + api = NativeApi("https://demo.dataverse.org", api_token) + assert api.api_token == api_token + assert isinstance(api.auth, ApiTokenAuth) + assert api.auth.api_token == api_token + + class TestApiRequests(object): """Test the native_api requests.""" From a49e644d8062f800e65dbc859d942339678a6c5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20H=C3=B6ffner?= Date: Thu, 18 Jul 2024 23:04:10 +0200 Subject: [PATCH 07/12] Deprecate auth Parameter of Individual Functions Instead, if required, use multiple instances of each API implementation. --- pyDataverse/api.py | 152 +++++++++++++++++++++++++++++++----------- tests/api/test_api.py | 21 ++++++ 2 files changed, 135 insertions(+), 38 deletions(-) diff --git a/pyDataverse/api.py b/pyDataverse/api.py index c01b51a..41fb33d 100644 --- a/pyDataverse/api.py +++ b/pyDataverse/api.py @@ -18,6 +18,8 @@ OperationFailedError, ) +DEPRECATION_GUARD = object() + class Api: """Base class. @@ -151,7 +153,7 @@ def __str__(self): """ return f"{self.__class__.__name__}: {self.base_url_api}" - def get_request(self, url, params=None, auth=False): + def get_request(self, url, params=None, auth=DEPRECATION_GUARD): """Make a GET request. Parameters @@ -161,8 +163,18 @@ def get_request(self, url, params=None, auth=False): params : dict Dictionary of parameters to be passed with the request. Defaults to `None`. - auth : bool - Should an api token be sent in the request. Defaults to `False`. + auth : Any + .. deprecated:: 0.3.4 + The auth parameter was ignored before version 0.3.4. + Please pass your auth to the Api instance directly, as + explained in :py:func:`Api.__init__`. + If you need multiple auth methods, create multiple + API instances: + + .. code-block:: python + + api = Api("https://demo.dataverse.org", auth=ApiTokenAuth("my_api_token")) + api_oauth = Api("https://demo.dataverse.org", auth=BearerTokenAuth("my_bearer_token")) Returns ------- @@ -170,6 +182,13 @@ def get_request(self, url, params=None, auth=False): Response object of httpx library. """ + if auth is not DEPRECATION_GUARD: + warn( + DeprecationWarning( + "The auth parameter is deprecated. Please pass your auth " + "arguments to the __init__ method instead." + ) + ) headers = {} headers["User-Agent"] = "pydataverse" @@ -188,7 +207,9 @@ def get_request(self, url, params=None, auth=False): params=params, ) - def post_request(self, url, data=None, auth=False, params=None, files=None): + def post_request( + self, url, data=None, auth=DEPRECATION_GUARD, params=None, files=None + ): """Make a POST request. params will be added as key-value pairs to the URL. @@ -199,8 +220,18 @@ def post_request(self, url, data=None, auth=False, params=None, files=None): Full URL. data : str Metadata as a json-formatted string. Defaults to `None`. - auth : bool - Should an api token be sent in the request. Defaults to `False`. + auth : Any + .. deprecated:: 0.3.4 + The auth parameter was ignored before version 0.3.4. + Please pass your auth to the Api instance directly, as + explained in :py:func:`Api.__init__`. + If you need multiple auth methods, create multiple + API instances: + + .. code-block:: python + + api = Api("https://demo.dataverse.org", auth=ApiTokenAuth("my_api_token")) + api_oauth = Api("https://demo.dataverse.org", auth=BearerTokenAuth("my_bearer_token")) files : dict e.g. :code:`files={'file': open('sample_file.txt','rb')}` params : dict @@ -213,6 +244,13 @@ def post_request(self, url, data=None, auth=False, params=None, files=None): Response object of httpx library. """ + if auth is not DEPRECATION_GUARD: + warn( + DeprecationWarning( + "The auth parameter is deprecated. Please pass your auth " + "arguments to the __init__ method instead." + ) + ) headers = {} headers["User-Agent"] = "pydataverse" @@ -242,7 +280,7 @@ def post_request(self, url, data=None, auth=False, params=None, files=None): **request_params, ) - def put_request(self, url, data=None, auth=False, params=None): + def put_request(self, url, data=None, auth=DEPRECATION_GUARD, params=None): """Make a PUT request. Parameters @@ -251,8 +289,18 @@ def put_request(self, url, data=None, auth=False, params=None): Full URL. data : str Metadata as a json-formatted string. Defaults to `None`. - auth : bool - Should an api token be sent in the request. Defaults to `False`. + auth : Any + .. deprecated:: 0.3.4 + The auth parameter was ignored before version 0.3.4. + Please pass your auth to the Api instance directly, as + explained in :py:func:`Api.__init__`. + If you need multiple auth methods, create multiple + API instances: + + .. code-block:: python + + api = Api("https://demo.dataverse.org", auth=ApiTokenAuth("my_api_token")) + api_oauth = Api("https://demo.dataverse.org", auth=BearerTokenAuth("my_bearer_token")) params : dict Dictionary of parameters to be passed with the request. Defaults to `None`. @@ -263,6 +311,13 @@ def put_request(self, url, data=None, auth=False, params=None): Response object of httpx library. """ + if auth is not DEPRECATION_GUARD: + warn( + DeprecationWarning( + "The auth parameter is deprecated. Please pass your auth " + "arguments to the __init__ method instead." + ) + ) headers = {} headers["User-Agent"] = "pydataverse" @@ -291,15 +346,25 @@ def put_request(self, url, data=None, auth=False, params=None): **request_params, ) - def delete_request(self, url, auth=False, params=None): + def delete_request(self, url, auth=DEPRECATION_GUARD, params=None): """Make a Delete request. Parameters ---------- url : str Full URL. - auth : bool - Should an api token be sent in the request. Defaults to `False`. + auth : Any + .. deprecated:: 0.3.4 + The auth parameter was ignored before version 0.3.4. + Please pass your auth to the Api instance directly, as + explained in :py:func:`Api.__init__`. + If you need multiple auth methods, create multiple + API instances: + + .. code-block:: python + + api = Api("https://demo.dataverse.org", auth=ApiTokenAuth("my_api_token")) + api_oauth = Api("https://demo.dataverse.org", auth=BearerTokenAuth("my_bearer_token")) params : dict Dictionary of parameters to be passed with the request. Defaults to `None`. @@ -310,6 +375,13 @@ def delete_request(self, url, auth=False, params=None): Response object of httpx library. """ + if auth is not DEPRECATION_GUARD: + warn( + DeprecationWarning( + "The auth parameter is deprecated. Please pass your auth " + "arguments to the __init__ method instead." + ) + ) headers = {} headers["User-Agent"] = "pydataverse" @@ -504,7 +576,7 @@ def get_datafile( no_var_header=None, image_thumb=None, is_pid=True, - auth=False, + auth=DEPRECATION_GUARD, ): """Download a datafile via the Dataverse Data Access API. @@ -557,7 +629,7 @@ def get_datafile( url += "imageThumb={0}".format(image_thumb) return self.get_request(url, auth=auth) - def get_datafiles(self, identifier, data_format=None, auth=False): + def get_datafiles(self, identifier, data_format=None, auth=DEPRECATION_GUARD): """Download a datafile via the Dataverse Data Access API. Get by file id (HTTP Request). @@ -585,7 +657,9 @@ def get_datafiles(self, identifier, data_format=None, auth=False): url += "?format={0}".format(data_format) return self.get_request(url, auth=auth) - def get_datafile_bundle(self, identifier, file_metadata_id=None, auth=False): + def get_datafile_bundle( + self, identifier, file_metadata_id=None, auth=DEPRECATION_GUARD + ): """Download a datafile in all its formats. HTTP Request: @@ -628,7 +702,7 @@ def get_datafile_bundle(self, identifier, file_metadata_id=None, auth=False): url += "?fileMetadataId={0}".format(file_metadata_id) return self.get_request(url, auth=auth) - def request_access(self, identifier, auth=True, is_filepid=False): + def request_access(self, identifier, auth=DEPRECATION_GUARD, is_filepid=False): """Request datafile access. This method requests access to the datafile whose id is passed on the behalf of an authenticated user whose key is passed. Note that not all datasets allow access requests to restricted files. @@ -649,7 +723,9 @@ def request_access(self, identifier, auth=True, is_filepid=False): ) return self.put_request(url, auth=auth) - def allow_access_request(self, identifier, do_allow=True, auth=True, is_pid=True): + def allow_access_request( + self, identifier, do_allow=True, auth=DEPRECATION_GUARD, is_pid=True + ): """Allow access request for datafiles. https://guides.dataverse.org/en/latest/api/dataaccess.html#allow-access-requests @@ -672,7 +748,7 @@ def allow_access_request(self, identifier, do_allow=True, auth=True, is_pid=True data = "false" return self.put_request(url, data=data, auth=auth) - def grant_file_access(self, identifier, user, auth=False): + def grant_file_access(self, identifier, user, auth=DEPRECATION_GUARD): """Grant datafile access. https://guides.dataverse.org/en/4.18.1/api/dataaccess.html#grant-file-access @@ -684,7 +760,7 @@ def grant_file_access(self, identifier, user, auth=False): ) return self.put_request(url, auth=auth) - def list_file_access_requests(self, identifier, auth=False): + def list_file_access_requests(self, identifier, auth=DEPRECATION_GUARD): """Liste datafile access requests. https://guides.dataverse.org/en/4.18.1/api/dataaccess.html#list-file-access-requests @@ -717,7 +793,7 @@ def __init__(self, base_url, api_token=None, api_version="latest", *, auth=None) else: self.base_url_api_metrics = None - def total(self, data_type, date_str=None, auth=False): + def total(self, data_type, date_str=None, auth=DEPRECATION_GUARD): """ GET https://$SERVER/api/info/metrics/$type GET https://$SERVER/api/info/metrics/$type/toMonth/$YYYY-DD @@ -730,7 +806,7 @@ def total(self, data_type, date_str=None, auth=False): url += "/toMonth/{0}".format(date_str) return self.get_request(url, auth=auth) - def past_days(self, data_type, days_str, auth=False): + def past_days(self, data_type, days_str, auth=DEPRECATION_GUARD): """ http://guides.dataverse.org/en/4.18.1/api/metrics.html @@ -744,7 +820,7 @@ def past_days(self, data_type, days_str, auth=False): ) return self.get_request(url, auth=auth) - def get_dataverses_by_subject(self, auth=False): + def get_dataverses_by_subject(self, auth=DEPRECATION_GUARD): """ GET https://$SERVER/api/info/metrics/dataverses/bySubject @@ -754,7 +830,7 @@ def get_dataverses_by_subject(self, auth=False): url = "{0}/dataverses/bySubject".format(self.base_url_api_metrics) return self.get_request(url, auth=auth) - def get_dataverses_by_category(self, auth=False): + def get_dataverses_by_category(self, auth=DEPRECATION_GUARD): """ GET https://$SERVER/api/info/metrics/dataverses/byCategory @@ -764,7 +840,7 @@ def get_dataverses_by_category(self, auth=False): url = "{0}/dataverses/byCategory".format(self.base_url_api_metrics) return self.get_request(url, auth=auth) - def get_datasets_by_subject(self, date_str=None, auth=False): + def get_datasets_by_subject(self, date_str=None, auth=DEPRECATION_GUARD): """ GET https://$SERVER/api/info/metrics/datasets/bySubject @@ -776,7 +852,7 @@ def get_datasets_by_subject(self, date_str=None, auth=False): url += "/toMonth/{0}".format(date_str) return self.get_request(url, auth=auth) - def get_datasets_by_data_location(self, data_location, auth=False): + def get_datasets_by_data_location(self, data_location, auth=DEPRECATION_GUARD): """ GET https://$SERVER/api/info/metrics/datasets/bySubject @@ -825,7 +901,7 @@ def __init__(self, base_url: str, api_token=None, api_version="v1", *, auth=None super().__init__(base_url, api_token, api_version, auth=auth) self.base_url_api_native = self.base_url_api - def get_dataverse(self, identifier, auth=False): + def get_dataverse(self, identifier, auth=DEPRECATION_GUARD): """Get dataverse metadata by alias or id. View metadata about a dataverse. @@ -1066,7 +1142,7 @@ def get_dataverse_contents(self, identifier, auth=True): url = "{0}/dataverses/{1}/contents".format(self.base_url_api_native, identifier) return self.get_request(url, auth=auth) - def get_dataverse_assignments(self, identifier, auth=False): + def get_dataverse_assignments(self, identifier, auth=DEPRECATION_GUARD): """Get dataverse assignments by alias or id. View assignments of a dataverse. @@ -1092,7 +1168,7 @@ def get_dataverse_assignments(self, identifier, auth=False): ) return self.get_request(url, auth=auth) - def get_dataverse_facets(self, identifier, auth=False): + def get_dataverse_facets(self, identifier, auth=DEPRECATION_GUARD): """Get dataverse facets by alias or id. View facets of a dataverse. @@ -1116,7 +1192,7 @@ def get_dataverse_facets(self, identifier, auth=False): url = "{0}/dataverses/{1}/facets".format(self.base_url_api_native, identifier) return self.get_request(url, auth=auth) - def dataverse_id2alias(self, dataverse_id, auth=False): + def dataverse_id2alias(self, dataverse_id, auth=DEPRECATION_GUARD): """Converts a Dataverse ID to an alias. Parameters @@ -1265,7 +1341,7 @@ def get_dataset_version(self, identifier, version, auth=True, is_pid=True): ) return self.get_request(url, auth=auth) - def get_dataset_export(self, pid, export_format, auth=False): + def get_dataset_export(self, pid, export_format, auth=DEPRECATION_GUARD): """Get metadata of dataset exported in different formats. Export the metadata of the current published version of a dataset @@ -1975,7 +2051,7 @@ def replace_datafile(self, identifier, filename, json_str, is_filepid=True): url += "/files/{0}/replace".format(identifier) return self.post_request(url, data=data, files=files, auth=True) - def get_info_version(self, auth=False): + def get_info_version(self, auth=DEPRECATION_GUARD): """Get the Dataverse version and build number. The response contains the version and build numbers. Requires no api @@ -1996,7 +2072,7 @@ def get_info_version(self, auth=False): url = "{0}/info/version".format(self.base_url_api_native) return self.get_request(url, auth=auth) - def get_info_server(self, auth=False): + def get_info_server(self, auth=DEPRECATION_GUARD): """Get dataverse server name. This is useful when a Dataverse system is composed of multiple Java EE @@ -2017,7 +2093,7 @@ def get_info_server(self, auth=False): url = "{0}/info/server".format(self.base_url_api_native) return self.get_request(url, auth=auth) - def get_info_api_terms_of_use(self, auth=False): + def get_info_api_terms_of_use(self, auth=DEPRECATION_GUARD): """Get API Terms of Use url. The response contains the text value inserted as API Terms of use which @@ -2038,7 +2114,7 @@ def get_info_api_terms_of_use(self, auth=False): url = "{0}/info/apiTermsOfUse".format(self.base_url_api_native) return self.get_request(url, auth=auth) - def get_metadatablocks(self, auth=False): + def get_metadatablocks(self, auth=DEPRECATION_GUARD): """Get info about all metadata blocks. Lists brief info about all metadata blocks registered in the system. @@ -2058,7 +2134,7 @@ def get_metadatablocks(self, auth=False): url = "{0}/metadatablocks".format(self.base_url_api_native) return self.get_request(url, auth=auth) - def get_metadatablock(self, identifier, auth=False): + def get_metadatablock(self, identifier, auth=DEPRECATION_GUARD): """Get info about single metadata block. Returns data about the block whose identifier is passed. identifier can @@ -2084,7 +2160,7 @@ def get_metadatablock(self, identifier, auth=False): url = "{0}/metadatablocks/{1}".format(self.base_url_api_native, identifier) return self.get_request(url, auth=auth) - def get_user_api_token_expiration_date(self, auth=False): + def get_user_api_token_expiration_date(self, auth=DEPRECATION_GUARD): """Get the expiration date of an Users's API token. HTTP Request: @@ -2163,7 +2239,7 @@ def create_role(self, dataverse_id): url = "{0}/roles?dvo={1}".format(self.base_url_api_native, dataverse_id) return self.post_request(url) - def show_role(self, role_id, auth=False): + def show_role(self, role_id, auth=DEPRECATION_GUARD): """Show role. `Docs `_ @@ -2506,7 +2582,7 @@ def search( filter_query=None, show_entity_ids=None, query_entities=None, - auth=False, + auth=DEPRECATION_GUARD, ): """Search. diff --git a/tests/api/test_api.py b/tests/api/test_api.py index 2fe0547..a248863 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -189,3 +189,24 @@ def test_token_should_not_be_exposed_on_error(self): result = api.get_datafile("does-not-exist").json() assert API_TOKEN not in result["requestUrl"] + + @pytest.mark.parametrize( + "auth", (True, False, "api-token", ApiTokenAuth("some-token")) + ) + def test_using_auth_on_individual_requests_is_deprecated(self, auth): + BASE_URL = os.getenv("BASE_URL") + API_TOKEN = os.getenv("API_TOKEN") + api = DataAccessApi(BASE_URL, auth=ApiTokenAuth(API_TOKEN)) + with pytest.warns(DeprecationWarning): + api.get_datafile("does-not-exist", auth=auth) + + @pytest.mark.parametrize( + "auth", (True, False, "api-token", ApiTokenAuth("some-token")) + ) + def test_using_auth_on_individual_requests_is_deprecated_unauthorized( + self, auth + ): + BASE_URL = os.getenv("BASE_URL") + no_auth_api = DataAccessApi(BASE_URL) + with pytest.warns(DeprecationWarning): + no_auth_api.get_datafile("does-not-exist", auth=auth) From 31e047bc801ea19348ef47e2bfc3fe1a4971bc26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20H=C3=B6ffner?= Date: Fri, 19 Jul 2024 01:36:01 +0200 Subject: [PATCH 08/12] Fix mypy Issues in api and conf.py Also add types-jsonschema as a lint dependency. --- pyDataverse/api.py | 12 ++++-------- pyproject.toml | 1 + 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/pyDataverse/api.py b/pyDataverse/api.py index 41fb33d..0164179 100644 --- a/pyDataverse/api.py +++ b/pyDataverse/api.py @@ -131,15 +131,11 @@ def __init__( ) ) - if self.base_url: - if self.api_version == "latest": - self.base_url_api = "{0}/api".format(self.base_url) - else: - self.base_url_api = "{0}/api/{1}".format( - self.base_url, self.api_version - ) + if self.api_version == "latest": + self.base_url_api = "{0}/api".format(self.base_url) else: - self.base_url_api = None + self.base_url_api = "{0}/api/{1}".format(self.base_url, self.api_version) + self.timeout = 500 def __str__(self): diff --git a/pyproject.toml b/pyproject.toml index 166356c..4e1e8de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,7 @@ optional = true black = "^24.3.0" radon = "^6.0.1" mypy = "^1.9.0" +types-jsonschema = "^4.23.0" autopep8 = "^2.1.0" ruff = "^0.4.4" From c351faa61eef2d1766e9aaedf0b143e720d224de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20H=C3=B6ffner?= Date: Fri, 19 Jul 2024 02:27:48 +0200 Subject: [PATCH 09/12] Add BasicAuth for SwordAPI --- pyDataverse/api.py | 18 ++++++++++++++++++ tests/api/test_api.py | 9 ++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/pyDataverse/api.py b/pyDataverse/api.py index 0164179..fcfeb5a 100644 --- a/pyDataverse/api.py +++ b/pyDataverse/api.py @@ -2647,8 +2647,26 @@ def __init__( ---------- sword_api_version : str Api version of Dataverse SWORD API. + api_token : str | None + An Api token as retrieved from your Dataverse instance. + auth : httpx.Auth + Note that the SWORD API uses a different authentication mechanism + than the native API, in particular it uses `HTTP Basic + Authentication + `_. + Thus, if you pass an api_token, it will be used as the username in + the HTTP Basic Authentication. If you pass a custom :py:class:`httpx.Auth`, use + :py:class:`httpx.BasicAuth` with an empty password: + + .. code-block:: python + + sword_api = Api( + "https://demo.dataverse.org", auth=httpx.BasicAuth(username="my_token", password="") + ) """ + if auth is None and api_token is not None: + auth = httpx.BasicAuth(api_token, "") super().__init__(base_url, api_token, api_version, auth=auth) if not isinstance(sword_api_version, ("".__class__, "".__class__)): raise ApiUrlError( diff --git a/tests/api/test_api.py b/tests/api/test_api.py index a248863..c8e7b72 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -1,8 +1,9 @@ import os +import httpx import pytest from httpx import Response from time import sleep -from pyDataverse.api import DataAccessApi, NativeApi +from pyDataverse.api import DataAccessApi, NativeApi, SwordApi from pyDataverse.auth import ApiTokenAuth from pyDataverse.exceptions import ApiAuthorizationError from pyDataverse.exceptions import ApiUrlError @@ -210,3 +211,9 @@ def test_using_auth_on_individual_requests_is_deprecated_unauthorized( no_auth_api = DataAccessApi(BASE_URL) with pytest.warns(DeprecationWarning): no_auth_api.get_datafile("does-not-exist", auth=auth) + + def test_sword_api_requires_http_basic_auth(self): + BASE_URL = os.getenv("BASE_URL") + API_TOKEN = os.getenv("API_TOKEN") + api = SwordApi(BASE_URL, api_token=API_TOKEN) + assert isinstance(api.auth, httpx.BasicAuth) From 5601c4aecde004e14d1fa5a6d15de440c5289784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20H=C3=B6ffner?= Date: Tue, 23 Jul 2024 18:59:59 +0200 Subject: [PATCH 10/12] Change Api to API and dataverse to Dataverse. Review for #192, #201. --- pyDataverse/api.py | 6 +++--- pyDataverse/auth.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pyDataverse/api.py b/pyDataverse/api.py index fcfeb5a..d3a77f7 100644 --- a/pyDataverse/api.py +++ b/pyDataverse/api.py @@ -90,8 +90,8 @@ def __init__( `httpx' Authentication docs `_. Examples - -------- - Create an Api connection:: + ------- + Create an API connection:: .. code-block:: @@ -891,7 +891,7 @@ def __init__(self, base_url: str, api_token=None, api_version="v1", *, auth=None Parameters ---------- native_api_version : str - Api version of Dataverse native api. Default is `v1`. + API version of Dataverse native API. Default is `v1`. """ super().__init__(base_url, api_token, api_version, auth=auth) diff --git a/pyDataverse/auth.py b/pyDataverse/auth.py index 9a04b4f..3e8c62b 100644 --- a/pyDataverse/auth.py +++ b/pyDataverse/auth.py @@ -21,7 +21,7 @@ def __init__(self, api_token: str): Parameters ---------- api_token : str - The API token retrieved from your dataverse instance user profile. + The API token retrieved from your Dataverse instance user profile. Examples -------- @@ -34,7 +34,7 @@ def __init__(self, api_token: str): """ if not isinstance(api_token, str): - raise ApiAuthorizationError("Api token passed is not a string.") + raise ApiAuthorizationError("API token passed is not a string.") self.api_token = api_token def auth_flow(self, request: Request) -> Generator[Request, Response, None]: @@ -82,7 +82,7 @@ def __init__(self, bearer_token: str): """ if not isinstance(bearer_token, str): - raise ApiAuthorizationError("Api token passed is not a string.") + raise ApiAuthorizationError("API token passed is not a string.") self.bearer_token = bearer_token def auth_flow(self, request: Request) -> Generator[Request, Response, None]: From c3b7a6720dd6b68a583127294ff44b2a89b305b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20H=C3=B6ffner?= Date: Tue, 23 Jul 2024 23:44:40 +0200 Subject: [PATCH 11/12] Add SWORD tests and make them pass The SWORD API returns an empty string for 401 Unauthorized. This would cause the usual response handling of extracting the error message from the JSON to raise, so we except that in that case. Resolves review comment on #201. --- pyDataverse/api.py | 9 +++++++-- tests/api/test_api.py | 13 +++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/pyDataverse/api.py b/pyDataverse/api.py index d3a77f7..a846a4e 100644 --- a/pyDataverse/api.py +++ b/pyDataverse/api.py @@ -447,9 +447,14 @@ def _sync_request( kwargs = self._filter_kwargs(kwargs) try: - resp = method(**kwargs, auth=self.auth, follow_redirects=True, timeout=None) + resp: httpx.Response = method( + **kwargs, auth=self.auth, follow_redirects=True, timeout=None + ) if resp.status_code == 401: - error_msg = resp.json()["message"] + try: + error_msg = resp.json()["message"] + except json.JSONDecodeError: + error_msg = resp.reason_phrase raise ApiAuthorizationError( "ERROR: HTTP 401 - Authorization error {0}. MSG: {1}".format( kwargs["url"], error_msg diff --git a/tests/api/test_api.py b/tests/api/test_api.py index c8e7b72..817c4b3 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -217,3 +217,16 @@ def test_sword_api_requires_http_basic_auth(self): API_TOKEN = os.getenv("API_TOKEN") api = SwordApi(BASE_URL, api_token=API_TOKEN) assert isinstance(api.auth, httpx.BasicAuth) + + def test_sword_api_can_authenticate(self): + BASE_URL = os.getenv("BASE_URL") + API_TOKEN = os.getenv("API_TOKEN") + api = SwordApi(BASE_URL, api_token=API_TOKEN) + response = api.get_service_document() + assert response.status_code == 200 + + def test_sword_api_cannot_authenticate_without_token(self): + BASE_URL = os.getenv("BASE_URL") + api = SwordApi(BASE_URL) + with pytest.raises(ApiAuthorizationError): + api.get_service_document() From 6ccc1939d6bd96a5091ebedac6597e84c64a1cf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20H=C3=B6ffner?= Date: Sat, 24 Aug 2024 17:22:26 +0200 Subject: [PATCH 12/12] Pass Correct Parameters to HTTP requests. This change was necessary due to rebasing the feature branch onto the json-data fixes in #203. Relates to #201. --- pyDataverse/api.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyDataverse/api.py b/pyDataverse/api.py index a846a4e..b364847 100644 --- a/pyDataverse/api.py +++ b/pyDataverse/api.py @@ -260,16 +260,15 @@ def post_request( return self._sync_request( method=httpx.post, url=url, - json=data, headers=headers, params=params, files=files, + **request_params, ) else: return self._async_request( method=self.client.post, url=url, - json=data, headers=headers, params=params, files=files,