Repository: gdcc/pyDataverse (language: python)

Issue:
Run pre-commit run --all
As a preparation for the PR for #192, I ran `pre-commit run --all` and commit the changes to a separate branch now.

This is the result, mostly some changed JSON files and a little bit of black formatting.

### All Submissions

**Describe your environment**

* [ ] OS: macOS, Sonoma 14.5, M1 <!-- (e. g. Linux, Ubuntu 18.04, 64bit) -->
* [ ] pyDataverse: main branch <!-- (e. g. 0.2.1) -->
* [ ] Python: 3.12 <!-- (e. g. 3.6.9) -->
* [ ] Dataverse: 6.3 (see #195) <!-- (optional, e. g. 4.18.1) -->

**Follow best practices**

* [x] Have you checked to ensure there aren't other open [Pull Requests](https://github.com/gdcc/pyDataverse/pulls) for the same update/change?
* [x] Have you followed the guidelines in our [Contribution Guide](https://pydataverse.readthedocs.io/en/master/contributing/contributing.html)?
* [x] Have you read the [Code of Conduct](https://github.com/gdcc/pyDataverse/blob/master/CODE_OF_CONDUCT.md)?
* [x] Do your changes in a seperate branch. Branches MUST have descriptive names.
* [x] Have you merged the latest changes from upstream to your branch?

**Describe the PR**

* [x] What kind of change does this PR introduce?
  * It formats some JSON files, a little bit of Python code and one missing EOL
* [x] Why is this change required? What problem does it solve?
  * It is not required, but it makes working with pre-commit a little easier to have a "clean base" – otherwise, future changes might suddenly reformat a while file for minor changes.
* [ ] Screenshots (if appropriate)
* [x] Put `Closes #ISSUE_NUMBER` to the end of this pull request <!-- (e. g. Closes #1234) -->

**Testing**

* [x] Have you used tox and/or pytest for testing the changes? pytest, run-tests.sh
* [x] Did the local testing ran successfully? yes, if changing DV_VERSION to 6.3.
* [ ] Did the Continous Integration testing (Travis-CI) ran successfully?

**Commits**

* [x] Have descriptive commit messages with a short title (first line).
* [x] Use the [commit message template](https://github.com/gdcc/pyDataverse/blob/master/.github/.gitmessage.txt)
* [x] Put `Closes #ISSUE_NUMBER` in your commit messages to auto-close the issue that it fixes (if such).

**Others**

* [ ] Is there anything you need from someone else? No.

### Code contribution

* [x] Have you used pre-commit? Yes, `pre-commit run --all` :)
* [x] Have you formatted your code with black prior to submission (e. g. via pre-commit)? yes
* [ ] Have you written new tests for your changes? No
* [ ] Have you ran mypy on your changes successfully? Not attempted
* [ ] Have you documented your update (Docstrings and/or Docs)? No
* [ ] Do your changes require additional changes to the documentation? No

Closes #194

Update the Contributor Guide
### Issue

While preparing my PR for #192, I noticed that the Contributor guide is a little outdated.

- One should now use poetry to install the dev-dependencies as specified in the pyproject.toml, in contrast to the [different requirements.txt files](https://pydataverse.readthedocs.io/en/master/contributing/contributing.html#creating-a-development-environment), however, poetry behaves nicely if one already has a .venv (as I learned "the hard way")
- `tox -e docs` does not work to build the docs, it expects a Python 3.6 which I don't have at hand and which is [EOL](https://devguide.python.org/versions/) anyways. Ubuntu 20 is still on 3.8, I think, but even the default for the tests is 3.11, so this should probably be updated. I built the docs with `sphinx-build pyDataverse/docs/source build` (inspired by the .readthedocs.yml) , but it couldn't find some of the rst files in the repository root. I ignored that issue as it was not relevant for me, but I'll check the tox.ini and see what one should configure instead, or what should be the working directory.
- The main branch is now `main` and no longer `master`.
- The testing section also mentions tox -py36, which should probably become -py311 (however, the tox environments are only py36,py37,py38 -- so maybe tox is just not really used anymore?)

I will update the points I mentioned here and create a PR.

Fix `jsonData` not passed correctly
**Overview**

This PR addresses the issue reported at https://github.com/datalad/datalad-dataverse/issues/320, where metadata sent via NativeApi.replace_datafile wasn’t updating the file metadata correctly. The problem arose because the jsonData payload needs to be sent as form-data, which HTTPX’s json kwarg doesn’t handle properly.

The fix involves sending the payload via the data argument. However, since other endpoints require data to be sent through the json argument, this PR introduces a private method in api.py to determine the correct way to send the data. This method checks for the presence of the jsonData key and adjusts the argument accordingly. If the key isn’t found, the json argument is used by default.

To prevent similar issues in the future, the tests have been updated. The replace_datafile tests now ensure that the metadata is correctly updated.

**TLDR**

* If `jsonData` key is present sent through `data` kwarg
* If not, JSON payload is sent through `json`
* Extended tests to verify metadata is updated correctly

Update DV_VERSION to 6.3
Version 6.3 is what the :unstable docker image currently provides.

### All Submissions

Inside container:
* [x] OS: I think the python images use ubuntu, but didn't check
* [x] pyDataverse: this PR (i.e., main branch + patch)
* [x] Python: 3.11
* [x] Dataverse: 6.3 (!) -- this is what this PR does

**Follow best practices**

* [x] Have you checked to ensure there aren't other open [Pull Requests](https://github.com/gdcc/pyDataverse/pulls) for the same update/change? yes, basically all due to the API version change.
* [x] Have you followed the guidelines in our [Contribution Guide](https://pydataverse.readthedocs.io/en/master/contributing/contributing.html)? Yes, but it seems a little outdated. Will create a follow-up issue for this one to update it.
* [x] Have you read the [Code of Conduct](https://github.com/gdcc/pyDataverse/blob/master/CODE_OF_CONDUCT.md)? Yes.
* [x] Do your changes in a separate branch. Branches MUST have descriptive names.
* [x] Have you merged the latest changes from upstream to your branch? Yes
**Describe the PR**

* [x] What kind of change does this PR introduce?
  * Updates DV_VERSION from 6.2 to 6.3
* [x] Why is this change required? What problem does it solve?
  * the :unstable docker image got updated 
* [x] Screenshots (if appropriate)
   <img width="305" alt="image" src="https://github.com/user-attachments/assets/460bed41-0af6-403b-abe5-4b4f9c6744d0">
* [x] Put `Closes #ISSUE_NUMBER` to the end of this pull request <!-- (e. g. Closes #1234) -->

**Testing**

* [x] Have you used tox and/or pytest for testing the changes? pytest, sh run-tests.sh
* [x] Did the local testing ran successfully? yes
* [ ] Did the Continuous Integration testing (Travis-CI) ran successfully?

**Commits**

* [x] Have descriptive commit messages with a short title (first line).
* [x] Use the [commit message template](https://github.com/gdcc/pyDataverse/blob/master/.github/.gitmessage.txt)
* [x] Put `Closes #ISSUE_NUMBER` in your commit messages to auto-close the issue that it fixes (if such).

**Others**

* [ ] Is there anything you need from someone else?

### Documentation contribution

* [ ] Have you followed NumPy Docstring standard?

### Code contribution

* [x] Have you used pre-commit?
* [x] Have you formatted your code with black prior to submission (e. g. via pre-commit)?
* [ ] Have you written new tests for your changes?
* [ ] Have you ran mypy on your changes successfully?
* [ ] Have you documented your update (Docstrings and/or Docs)?
* [ ] Do your changes require additional changes to the documentation?


Closes #195 

API Key and User-Agent should preferrably be passed as headers
### Issue

The API key and User-Agent are currently passed as GET parameters, ideally they should be passed as headers. User-Agents are commonly passed as headers, and for the key this is [actually preferred](https://guides.dataverse.org/en/latest/api/auth.html).
It also is slightly more secure, as the dataverse API will return the full request URL on error, possibly exposing API keys in logs:

```json
{"status":"ERROR","code":404,"message":"API endpoint does not exist on this server. Please check your code for typos, or consult our API guide at http://guides.dataverse.org.","requestUrl":"http://localhost:8080/api/v1/access/datafile/:persistentId/?persistentId=23&User-Agent=pydataverse&key=<the API token>","requestMethod":"GET"}
```
(taken from datalad/datalad-dataverse#310, where I found out about this; it also contains a little bit of context)

I am not sure if this request is a bug or feature, so I decided to open a normal issue. The pyDataverse version I use is 0.3.3, and the code doing this is in pyDataverse/api.py:

https://github.com/gdcc/pyDataverse/blob/3b6fe063dfc73d6fa3aa674cc02313504c40204f/pyDataverse/api.py#L103-L138

The same is done for post_request, etc.

A possible change would be to use headers and pass them as headers:
```python
        headers = {}
        headers["User-Agent"] = "pydataverse"
        if self.api_token:
            headers["X-Dataverse-key"] = str(self.api_token)

        if self.client is None:
            return self._sync_request(
                method=httpx.get,
                url=url,
                headers=headers,
            )
```

Additionally, I noticed that neither the `params` nor the `auth` attributes are used.

For the params a fix could be, especially after moving the key and user-agent to the headers:

```python
self._sync_request(
    method=httpx.get,
    url=url,
    headers=headers,
    params=params or {},  # maybe httpx.get even handles params=None, in that case they could simply be passed through
)
```

For the auth parameter it's a bit more difficult, since it currently does not affect the behavior although it should.
One possible way would be to update the if statement before adding the token to the header:
```
if auth and self.api_token:
```
However, it might make sense to change the default value to True in this case, to keep the behavior stable. However, I am not 100% sure what is the best way to handle this. Of course, one option would also be to remove the auth argument completely.

Lastly, the docstring of the function mentions the requests.Response, while it's now a httpx.Response. While they do have a similar interface, they have some minor differences; e.g., requests has an .ok property and uses iter_content, while httpx does not have .ok and uses iter_bytes.

I think I could try to "fix" this and draft a PR.

API Key and User-Agent should preferrably be passed as headers
### Issue

The API key and User-Agent are currently passed as GET parameters, ideally they should be passed as headers. User-Agents are commonly passed as headers, and for the key this is [actually preferred](https://guides.dataverse.org/en/latest/api/auth.html).
It also is slightly more secure, as the dataverse API will return the full request URL on error, possibly exposing API keys in logs:

```json
{"status":"ERROR","code":404,"message":"API endpoint does not exist on this server. Please check your code for typos, or consult our API guide at http://guides.dataverse.org.","requestUrl":"http://localhost:8080/api/v1/access/datafile/:persistentId/?persistentId=23&User-Agent=pydataverse&key=<the API token>","requestMethod":"GET"}
```
(taken from datalad/datalad-dataverse#310, where I found out about this; it also contains a little bit of context)

I am not sure if this request is a bug or feature, so I decided to open a normal issue. The pyDataverse version I use is 0.3.3, and the code doing this is in pyDataverse/api.py:

https://github.com/gdcc/pyDataverse/blob/3b6fe063dfc73d6fa3aa674cc02313504c40204f/pyDataverse/api.py#L103-L138

The same is done for post_request, etc.

A possible change would be to use headers and pass them as headers:
```python
        headers = {}
        headers["User-Agent"] = "pydataverse"
        if self.api_token:
            headers["X-Dataverse-key"] = str(self.api_token)

        if self.client is None:
            return self._sync_request(
                method=httpx.get,
                url=url,
                headers=headers,
            )
```

Additionally, I noticed that neither the `params` nor the `auth` attributes are used.

For the params a fix could be, especially after moving the key and user-agent to the headers:

```python
self._sync_request(
    method=httpx.get,
    url=url,
    headers=headers,
    params=params or {},  # maybe httpx.get even handles params=None, in that case they could simply be passed through
)
```

For the auth parameter it's a bit more difficult, since it currently does not affect the behavior although it should.
One possible way would be to update the if statement before adding the token to the header:
```
if auth and self.api_token:
```
However, it might make sense to change the default value to True in this case, to keep the behavior stable. However, I am not 100% sure what is the best way to handle this. Of course, one option would also be to remove the auth argument completely.

Lastly, the docstring of the function mentions the requests.Response, while it's now a httpx.Response. While they do have a similar interface, they have some minor differences; e.g., requests has an .ok property and uses iter_content, while httpx does not have .ok and uses iter_bytes.

I think I could try to "fix" this and draft a PR.

Rework auth/api_token parameters
**Describe your environment**

On host:
* [x] OS: macOS, Sonoma 14.5, M1
* [x] pyDataverse: this PR (i.e., main branch + patches)
* [x] Python: 3.12
* [x] Dataverse: 6.3 (local/container), 6.2 (demo.dataverse.org)

Inside container:
* [x] OS: I think the python images use ubuntu, but didn't check
* [x] pyDataverse: this PR (i.e., main branch + patches)
* [x] Python: 3.11
* [x] Dataverse: 6.3 (!) -- had to override DV_VERSION in docker-compose-test-all.yml to make tests pass, as three tests check for the version. See also #197 .


**Follow best practices**

* [x] Have you checked to ensure there aren't other open [Pull Requests](https://github.com/gdcc/pyDataverse/pulls) for the same update/change? #146 is partially affected by this as it changes the params argument of `put_request`.
* [x] Have you followed the guidelines in our [Contribution Guide](https://pydataverse.readthedocs.io/en/master/contributing/contributing.html)? Yes, see #193 .
* [x] Have you read the [Code of Conduct](https://github.com/gdcc/pyDataverse/blob/master/CODE_OF_CONDUCT.md)? Yes.
* [x] Do your changes in a separate branch. Branches MUST have descriptive names.
* [x] Have you merged the latest changes from upstream to your branch? Yes

**Describe the PR**

* [x] What kind of change does this PR introduce?
  * This PR introduces custom authentication implementation to allow for API token and Bearer token-based authentication.
  * Sword requires BasicAuth, which this PR also addresses (see https://guides.dataverse.org/en/latest/api/sword.html#sword-auth)
  * An Auth implementation must be passed as a keyword-argument to the __init__ function. We can discuss this requirement, but it felt better to be explicit about it and make sure implementers actually pass it correctly rather than accidentally passing it as the api_token or something.
  * I replaced some `isinstance(..., ("".__class__, "".__class__))` with the much more legible and equivalent (as far as I can tell) `isinstance(..., str)`.
  * It also deprecates the use of the previously ignored `auth` on individual functions in favor of generating multiple API objects with different auth methods.
    * For the deprecation, I introduced a DEPRECATION_GUARD to warn people who are trying to pass something to the functions. Initially I wanted to check for `False`, but there was one call defaulting to `True` (`get_access`) and this would not catch code explicitly setting `auth=False` on the callsite. So I decided to introduce a new default argument and show a warning if it is overwritten. Happy to discuss this decision – I have never done such an orderly deprecation before, so this is my first try :)
  * I updated the documentation.
  * Oh, and I smuggled a change to the `Api.__str__` methods into the PR. As I scrolled by, I saw that they essentially could be removed and just the `Api.__str__` method itself should use `self.__class__.__name__` to get a similar result. However, the DataAccessApi will not print `DataAccessApi: ...` instead of the previous `Data Access API: ...` – the same applies for the others. However, this felt to be more in spirit with the docstring (which I then also updated slightly). If that's not so nice, we can move that to its own PR or even drop that commit entirely.
  * I had to adapt three unrelated things (conf.py, the `self.base_url = None` assignment in api.py, and the pyproject.toml) for mypy.
  * This PR almost accidentally also allows to pass `params` properly due to the name change of params -> headers.
* [x] Why is this change required? What problem does it solve?
  * First and foremost, the issue it resolves is that tokens could potentially be leaked when sharing log messages, as the error response from dataverse includes the request URL, which, if the token is provided as ?key, contains the key.
  * The PR also lays the groundwork to allow for different authentication schemas in the future and even to configure it from the outside, favoring composition over inheritance. This will hopefully make maintenance and the refactoring in #189 easier.
  * By deprecating the auth on individual methods, it will eventually be possible to remove this unused argument and make the API a little bit leaner and reduce confusing behavior.
* [ ] Screenshots (if appropriate)
* [x] Put `Closes #ISSUE_NUMBER` to the end of this pull request

**Testing**

* [x] Have you used tox and/or pytest for testing the changes? pytest
* [x] Did the local testing ran successfully? yes, if applying #197. For demo.dataverse.org, I could not run test_upload.py successfully, as I was unauthorized to perform those uploads (401). However, locally (outside the containers) I was able to run the asyncio tests with pytest-asyncio. The containers seem to miss this.
* [ ] Did the Continuous Integration testing (Travis-CI) ran successfully?

**Commits**

* [x] Have descriptive commit messages with a short title (first line).
* [x] Use the [commit message template](https://github.com/gdcc/pyDataverse/blob/master/.github/.gitmessage.txt)
* [x] Put `Closes #ISSUE_NUMBER` in your commit messages to auto-close the issue that it fixes (if such).
  * I have only done this for 3e73fbf, as that is arguably the "essence" of this PR.

**Others**

* [x] Is there anything you need from someone else? Take your time to review these changes. While some are quite repetitive and I already split some parts off into other PRs, this is a massive changeset due to the deprecation. In fact, maybe the deprecation should be its own PR? What do you think?

### Documentation contribution

* [?] Have you followed NumPy Docstring standard? I hope so

### Code contribution

* [x] Have you used pre-commit? Yes, see also #196.
* [x] Have you formatted your code with black prior to submission (e. g. via pre-commit)? Yes
* [x] Have you written new tests for your changes? Yes
* [x] Have you ran mypy on your changes successfully? Yes, but I had to add types-jsonschema to the lint dependencies and in the conf.py. I haven't tried --strict.
* [x] Have you documented your update (Docstrings and/or Docs)? yes
* [x] Do your changes require additional changes to the documentation? No, but m

Note that this PR depends on other PRs which should be reviewed and decided/acted upon first, I can also rebase/merge the changes into this branch afterwards.
In particular:
- #196
- #197

Closes #192 .

Rework auth/api_token parameters
**Describe your environment**

On host:
* [x] OS: macOS, Sonoma 14.5, M1
* [x] pyDataverse: this PR (i.e., main branch + patches)
* [x] Python: 3.12
* [x] Dataverse: 6.3 (local/container), 6.2 (demo.dataverse.org)

Inside container:
* [x] OS: I think the python images use ubuntu, but didn't check
* [x] pyDataverse: this PR (i.e., main branch + patches)
* [x] Python: 3.11
* [x] Dataverse: 6.3 (!) -- had to override DV_VERSION in docker-compose-test-all.yml to make tests pass, as three tests check for the version. See also #197 .


**Follow best practices**

* [x] Have you checked to ensure there aren't other open [Pull Requests](https://github.com/gdcc/pyDataverse/pulls) for the same update/change? #146 is partially affected by this as it changes the params argument of `put_request`.
* [x] Have you followed the guidelines in our [Contribution Guide](https://pydataverse.readthedocs.io/en/master/contributing/contributing.html)? Yes, see #193 .
* [x] Have you read the [Code of Conduct](https://github.com/gdcc/pyDataverse/blob/master/CODE_OF_CONDUCT.md)? Yes.
* [x] Do your changes in a separate branch. Branches MUST have descriptive names.
* [x] Have you merged the latest changes from upstream to your branch? Yes

**Describe the PR**

* [x] What kind of change does this PR introduce?
  * This PR introduces custom authentication implementation to allow for API token and Bearer token-based authentication.
  * Sword requires BasicAuth, which this PR also addresses (see https://guides.dataverse.org/en/latest/api/sword.html#sword-auth)
  * An Auth implementation must be passed as a keyword-argument to the __init__ function. We can discuss this requirement, but it felt better to be explicit about it and make sure implementers actually pass it correctly rather than accidentally passing it as the api_token or something.
  * I replaced some `isinstance(..., ("".__class__, "".__class__))` with the much more legible and equivalent (as far as I can tell) `isinstance(..., str)`.
  * It also deprecates the use of the previously ignored `auth` on individual functions in favor of generating multiple API objects with different auth methods.
    * For the deprecation, I introduced a DEPRECATION_GUARD to warn people who are trying to pass something to the functions. Initially I wanted to check for `False`, but there was one call defaulting to `True` (`get_access`) and this would not catch code explicitly setting `auth=False` on the callsite. So I decided to introduce a new default argument and show a warning if it is overwritten. Happy to discuss this decision – I have never done such an orderly deprecation before, so this is my first try :)
  * I updated the documentation.
  * Oh, and I smuggled a change to the `Api.__str__` methods into the PR. As I scrolled by, I saw that they essentially could be removed and just the `Api.__str__` method itself should use `self.__class__.__name__` to get a similar result. However, the DataAccessApi will not print `DataAccessApi: ...` instead of the previous `Data Access API: ...` – the same applies for the others. However, this felt to be more in spirit with the docstring (which I then also updated slightly). If that's not so nice, we can move that to its own PR or even drop that commit entirely.
  * I had to adapt three unrelated things (conf.py, the `self.base_url = None` assignment in api.py, and the pyproject.toml) for mypy.
  * This PR almost accidentally also allows to pass `params` properly due to the name change of params -> headers.
* [x] Why is this change required? What problem does it solve?
  * First and foremost, the issue it resolves is that tokens could potentially be leaked when sharing log messages, as the error response from dataverse includes the request URL, which, if the token is provided as ?key, contains the key.
  * The PR also lays the groundwork to allow for different authentication schemas in the future and even to configure it from the outside, favoring composition over inheritance. This will hopefully make maintenance and the refactoring in #189 easier.
  * By deprecating the auth on individual methods, it will eventually be possible to remove this unused argument and make the API a little bit leaner and reduce confusing behavior.
* [ ] Screenshots (if appropriate)
* [x] Put `Closes #ISSUE_NUMBER` to the end of this pull request

**Testing**

* [x] Have you used tox and/or pytest for testing the changes? pytest
* [x] Did the local testing ran successfully? yes, if applying #197. For demo.dataverse.org, I could not run test_upload.py successfully, as I was unauthorized to perform those uploads (401). However, locally (outside the containers) I was able to run the asyncio tests with pytest-asyncio. The containers seem to miss this.
* [ ] Did the Continuous Integration testing (Travis-CI) ran successfully?

**Commits**

* [x] Have descriptive commit messages with a short title (first line).
* [x] Use the [commit message template](https://github.com/gdcc/pyDataverse/blob/master/.github/.gitmessage.txt)
* [x] Put `Closes #ISSUE_NUMBER` in your commit messages to auto-close the issue that it fixes (if such).
  * I have only done this for 3e73fbf, as that is arguably the "essence" of this PR.

**Others**

* [x] Is there anything you need from someone else? Take your time to review these changes. While some are quite repetitive and I already split some parts off into other PRs, this is a massive changeset due to the deprecation. In fact, maybe the deprecation should be its own PR? What do you think?

### Documentation contribution

* [?] Have you followed NumPy Docstring standard? I hope so

### Code contribution

* [x] Have you used pre-commit? Yes, see also #196.
* [x] Have you formatted your code with black prior to submission (e. g. via pre-commit)? Yes
* [x] Have you written new tests for your changes? Yes
* [x] Have you ran mypy on your changes successfully? Yes, but I had to add types-jsonschema to the lint dependencies and in the conf.py. I haven't tried --strict.
* [x] Have you documented your update (Docstrings and/or Docs)? yes
* [x] Do your changes require additional changes to the documentation? No, but m

Note that this PR depends on other PRs which should be reviewed and decided/acted upon first, I can also rebase/merge the changes into this branch afterwards.
In particular:
- #196
- #197

Closes #192 .



Discussion / hints:




Thanks for pointing these issues out, @shoeffner! I completely agree – the API Key should be included in the headers, and it looks like `auth` and `params` are either redundant or unused. Here’s what I’m thinking:

* Option 1: We put the API-Token directly into the headers and remove the auth parameter.
* Option 2: We create a custom class using `httpx.Auth` to add the key to the header, like in this [example](https://www.python-httpx.org/advanced/authentication/). The auth parameter stays, and if it’s None, our custom class handles authentication.

The custom class seems more future-proof, especially if Dataverse introduces new authentication methods. The direct fix is quicker, but I’m leaning towards the custom class and keeping auth.

As for the `params` argument, we haven’t used it yet since query parameters are added to the URL string directly. However, I think we’re missing out on some great httpx features that could make our code more readable and stable. This would mean refactoring most endpoints, and since we’ve already discussed a refactor in #189, I suggest we include this in the `api.py` update.
Reworking `auth` to be non-boolean but instead httpx.Auth or None sounds like a great idea! In fact, in this case it might be as simple as adding 
> A callable, accepting a request and returning an authenticated request instance,
so there might not be a class required after all to get the mechanism going.

I can't say much about the refactoring in #189, as I am not familiar with the code base in general. But I agree that using a dict for params instead of manually building up the URLs is a good idea.

Let me know if I can help with anything.


Great, that sounds like a plan! Would you be willing to open a PR for this issue? I’d be happy to help out with it.

By the way, we talked about this in today’s PyDataverse Working Group meeting with @pdurbin and @donsizemore. You can watch the recording [here](https://drive.google.com/file/d/1Jkk1RWPwV_6YW3KdkrZFFEfL6MlFLta9/view?usp=share_link) and check out our upcoming meetings on [py.gdcc.io](https://py.gdcc.io/) if you'd like to join next time. For discussions outside of these meetings, we also have a [Zulip channel](https://dataverse.zulipchat.com/#narrow/stream/377090-python/topic/PyDataverse.20Re-Vamp) for everything related to Python and Dataverse.
Thanks @JR-1991, I'll take a chance implementing that.
I checked the recording (here's my gist from the relevant parts of the recording, for those in a rush and cannot access the notes or do not want to go through the recording):

- It's in general a good idea to refactor the authentication to use httpx.Auth
- While using the header (X-Dataverse-key) is a good first step, an even better but "create-new-issue-and-check-out-in-the-future" way is to use [signed URLs](https://guides.dataverse.org/en/latest/api/external-tools.html#signed-urls)

There was not much talk about the User-Agent or params, but as @JR-1991 already mentioned that the params might become part of #189, I'll leave that part out and focus on the first step for the authentication, that is using the X-Dataverse-key header.
> focus on the first step for the authentication, that is using the X-Dataverse-key header

Sounds perfect. Thanks, @shoeffner! ❤️ 

You're welcome to test against https://demo.dataverse.org

Or you can run Dataverse in Docker, as mentioned in the README.
Thanks, I'll focus on the docker-setup for the moment, and once it's done and green, I'll go for the live instance :)
Thanks for pointing these issues out, @shoeffner! I completely agree – the API Key should be included in the headers, and it looks like `auth` and `params` are either redundant or unused. Here’s what I’m thinking:

* Option 1: We put the API-Token directly into the headers and remove the auth parameter.
* Option 2: We create a custom class using `httpx.Auth` to add the key to the header, like in this [example](https://www.python-httpx.org/advanced/authentication/). The auth parameter stays, and if it’s None, our custom class handles authentication.

The custom class seems more future-proof, especially if Dataverse introduces new authentication methods. The direct fix is quicker, but I’m leaning towards the custom class and keeping auth.

As for the `params` argument, we haven’t used it yet since query parameters are added to the URL string directly. However, I think we’re missing out on some great httpx features that could make our code more readable and stable. This would mean refactoring most endpoints, and since we’ve already discussed a refactor in #189, I suggest we include this in the `api.py` update.
Reworking `auth` to be non-boolean but instead httpx.Auth or None sounds like a great idea! In fact, in this case it might be as simple as adding 
> A callable, accepting a request and returning an authenticated request instance,
so there might not be a class required after all to get the mechanism going.

I can't say much about the refactoring in #189, as I am not familiar with the code base in general. But I agree that using a dict for params instead of manually building up the URLs is a good idea.

Let me know if I can help with anything.


Great, that sounds like a plan! Would you be willing to open a PR for this issue? I’d be happy to help out with it.

By the way, we talked about this in today’s PyDataverse Working Group meeting with @pdurbin and @donsizemore. You can watch the recording [here](https://drive.google.com/file/d/1Jkk1RWPwV_6YW3KdkrZFFEfL6MlFLta9/view?usp=share_link) and check out our upcoming meetings on [py.gdcc.io](https://py.gdcc.io/) if you'd like to join next time. For discussions outside of these meetings, we also have a [Zulip channel](https://dataverse.zulipchat.com/#narrow/stream/377090-python/topic/PyDataverse.20Re-Vamp) for everything related to Python and Dataverse.
Thanks @JR-1991, I'll take a chance implementing that.
I checked the recording (here's my gist from the relevant parts of the recording, for those in a rush and cannot access the notes or do not want to go through the recording):

- It's in general a good idea to refactor the authentication to use httpx.Auth
- While using the header (X-Dataverse-key) is a good first step, an even better but "create-new-issue-and-check-out-in-the-future" way is to use [signed URLs](https://guides.dataverse.org/en/latest/api/external-tools.html#signed-urls)

There was not much talk about the User-Agent or params, but as @JR-1991 already mentioned that the params might become part of #189, I'll leave that part out and focus on the first step for the authentication, that is using the X-Dataverse-key header.
> focus on the first step for the authentication, that is using the X-Dataverse-key header

Sounds perfect. Thanks, @shoeffner! ❤️ 

You're welcome to test against https://demo.dataverse.org

Or you can run Dataverse in Docker, as mentioned in the README.
Thanks, I'll focus on the docker-setup for the moment, and once it's done and green, I'll go for the live instance :)



Fix patch:
diff --git a/pyDataverse/api.py b/pyDataverse/api.py
index a15decc..b364847 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,
@@ -16,6 +18,8 @@
     OperationFailedError,
 )
 
+DEPRECATION_GUARD = object()
+
 
 class Api:
     """Base class.
@@ -41,6 +45,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 +58,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
+            <https://www.python-httpx.org/advanced/authentication/>`_.
         Examples
-        --------
-        Create an Api connection::
+        -------
+        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))
@@ -69,28 +113,33 @@ 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__)):
-                raise ApiAuthorizationError("Api token passed is not a string.")
+        self.auth = auth
         self.api_token = api_token
-
-        if self.base_url:
-            if self.api_version == "latest":
-                self.base_url_api = "{0}/api".format(self.base_url)
+        if api_token is not None:
+            if auth is None:
+                self.auth = ApiTokenAuth(api_token)
             else:
-                self.base_url_api = "{0}/api/{1}".format(
-                    self.base_url, self.api_version
+                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.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):
-        """Return name of Api() class for users.
+        """Return the class name and URL of the used API class.
 
         Returns
         -------
@@ -98,9 +147,9 @@ 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):
+    def get_request(self, url, params=None, auth=DEPRECATION_GUARD):
         """Make a GET request.
 
         Parameters
@@ -110,8 +159,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
         -------
@@ -119,25 +178,34 @@ 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)
+        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"
 
         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,
             )
 
-    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.
@@ -148,8 +216,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
@@ -162,10 +240,15 @@ 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
+        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"
 
         if isinstance(data, str):
             data = json.loads(data)
@@ -175,18 +258,24 @@ 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,
+                headers=headers,
+                params=params,
+                files=files,
+                **request_params,
             )
         else:
             return self._async_request(
                 method=self.client.post,
                 url=url,
+                headers=headers,
                 params=params,
                 files=files,
                 **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
@@ -195,8 +284,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`.
@@ -207,10 +306,15 @@ 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
+        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"
 
         if isinstance(data, str):
             data = json.loads(data)
@@ -222,6 +326,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,19 +335,31 @@ 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,
             )
 
-    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`.
@@ -252,21 +370,28 @@ 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
+        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"
 
         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,9 +446,14 @@ def _sync_request(
         kwargs = self._filter_kwargs(kwargs)
 
         try:
-            resp = method(**kwargs, 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
@@ -364,7 +494,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,25 +561,14 @@ 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:
             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,
@@ -457,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.
 
@@ -510,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).
@@ -538,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:
@@ -581,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.
@@ -602,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
@@ -625,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
@@ -637,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
@@ -662,26 +785,15 @@ 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:
             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):
+    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
@@ -694,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
@@ -708,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
 
@@ -718,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
 
@@ -728,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
 
@@ -740,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
 
@@ -774,7 +886,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.
@@ -783,24 +895,13 @@ def __init__(self, base_url: str, api_token=None, api_version="v1"):
         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)
+        super().__init__(base_url, api_token, api_version, auth=auth)
         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):
+    def get_dataverse(self, identifier, auth=DEPRECATION_GUARD):
         """Get dataverse metadata by alias or id.
 
         View metadata about a dataverse.
@@ -1041,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.
@@ -1067,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.
@@ -1091,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
@@ -1240,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
@@ -1950,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
@@ -1971,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
@@ -1992,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
@@ -2013,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.
@@ -2033,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
@@ -2059,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:
@@ -2138,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 <https://guides.dataverse.org/en/latest/api/native-api.html#show-role>`_
@@ -2459,25 +2560,14 @@ 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:
             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,
@@ -2492,7 +2582,7 @@ def search(
         filter_query=None,
         show_entity_ids=None,
         query_entities=None,
-        auth=False,
+        auth=DEPRECATION_GUARD,
     ):
         """Search.
 
@@ -2547,7 +2637,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 <pyDataverse.api.SwordApi>` instance.
 
@@ -2555,9 +2651,27 @@ 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
+            <https://guides.dataverse.org/en/latest/api/sword.html#sword-auth>`_.
+            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="")
+                )
 
         """
-        super().__init__(base_url, api_token, api_version)
+        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(
                 "sword_api_version {0} is not a string.".format(sword_api_version)
@@ -2572,17 +2686,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)
diff --git a/pyDataverse/auth.py b/pyDataverse/auth.py
new file mode 100644
index 0000000..3e8c62b
--- /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
+    <https://datatracker.ietf.org/doc/html/rfc6750>`_ 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..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
@@ -38,6 +39,13 @@ Helper functions.
   :members:
 
 
+Auth Helpers
+-----------------------------
+
+.. automodule:: pyDataverse.auth
+  :members:
+
+
 Exceptions
 -----------------------------
 
diff --git a/pyproject.toml b/pyproject.toml
index 6c513fa..4e1e8de 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"
 
@@ -64,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"
 


Test patch:
diff --git a/tests/api/test_api.py b/tests/api/test_api.py
index f882127..817c4b3 100644
--- a/tests/api/test_api.py
+++ b/tests/api/test_api.py
@@ -1,8 +1,10 @@
 import os
+import httpx
 import pytest
 from httpx import Response
 from time import sleep
-from pyDataverse.api import NativeApi
+from pyDataverse.api import DataAccessApi, NativeApi, SwordApi
+from pyDataverse.auth import ApiTokenAuth
 from pyDataverse.exceptions import ApiAuthorizationError
 from pyDataverse.exceptions import ApiUrlError
 from pyDataverse.models import Dataset
@@ -34,6 +36,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."""
 
@@ -150,3 +182,51 @@ 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"]
+
+        @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)
+
+        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)
+
+        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()
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)