Skip to content

Commit ddbe351

Browse files
committed
DEVEXP-495 Token is now renewed automatically.
Also adjusted how the client is imported so less typing is required.
1 parent 09feaeb commit ddbe351

File tree

8 files changed

+114
-25
lines changed

8 files changed

+114
-25
lines changed

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ ensuring you've done that, start a new Python shell by running `python`.
3939

4040
You can then import the client via:
4141

42-
from marklogic.client import Client
42+
from marklogic import Client
4343

4444
You can instantiate an instance of the client that communicates with this project's test application via:
4545

docs/getting-started.md

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ as you'd use either the `Session` class or the `requests` API.
1919

2020
A `Client` instance can be constructed either by providing a base URL for all requests along with authentication:
2121

22-
from marklogic.client import Client
22+
from marklogic import Client
2323
client = Client('http://localhost:8030', digest=('username', 'password'))
2424

2525
Or via separate arguments for each of the parts of a base URL:
2626

27-
from marklogic.client import Client
27+
from marklogic import Client
2828
client = Client(host='localhost', port='8030', digest=('username', 'password'))
2929

3030
After constructing a `Client` instance, each of the methods in the `requests` API for sending an HTTP request can be
@@ -43,14 +43,14 @@ Because the `Client` class extends the `Sessions` class, it can be used as a con
4343

4444
The `Client` constructor includes a `digest` argument as a convenience for using digest authentication:
4545

46-
from marklogic.client import Client
46+
from marklogic import Client
4747
client = Client('http://localhost:8030', digest=('username', 'password'))
4848

4949
An `auth` argument is also available for using any authentication strategy that can be configured
5050
[via the requests `auth` argument](https://requests.readthedocs.io/en/latest/user/advanced/#custom-authentication). For
5151
example, just like with `requests`, a tuple can be passed to the `auth` argument to use basic authentication:
5252

53-
from marklogic.client import Client
53+
from marklogic import Client
5454
client = Client('http://localhost:8030', auth=('username', 'password'))
5555

5656
### MarkLogic Cloud Authentication
@@ -59,26 +59,35 @@ When connecting to a [MarkLogic Cloud instance](https://developer.marklogic.com/
5959
the `cloud_api_key` and `base_path` arguments. You only need to specify a `host` as well, as port 443 and HTTPS will be
6060
used by default. For example:
6161

62-
from marklogic.client import Client
62+
from marklogic import Client
6363
client = Client(host='example.marklogic.cloud', cloud_api_key='some-key-value', base_path='/ml/example/manage')
6464

6565
You may still use a full base URL if you wish:
6666

67-
from marklogic.client import Client
67+
from marklogic import Client
6868
client = Client('https://example.marklogic.cloud', cloud_api_key='some-key-value', base_path='/ml/example/manage')
6969

70-
70+
MarkLogic Cloud uses an access token for authentication; the access token is generated using the API key value. In some
71+
scenarios, you may wish to set the token expiration time to a value other than the default used by MarkLogic Cloud. To
72+
do so, set the `cloud_token_duration` argument to a number greater than zero that defines the token duration in
73+
minutes:
74+
75+
from marklogic import Client
76+
# Sets a token duration of 10 minutes.
77+
client = Client(host='example.marklogic.cloud', cloud_api_key='some-key-value', base_path='/ml/example/manage',
78+
cloud_token_duration=10)
79+
7180
## SSL
7281

7382
Configuring SSL connections is the same as
7483
[documented for the `requests` library](https://requests.readthedocs.io/en/latest/user/advanced/#ssl-cert-verification).
7584
As a convience, the `Client` constructor includes a `verify` argument so that it does not need to be configured on the
7685
`Client` instance after it's been constructed nor on every request:
7786

78-
from marklogic.client import Client
87+
from marklogic import Client
7988
client = Client('https://localhost:8030', digest=('username', 'password'), verify='/path/to/cert.pem')
8089

8190
When specifying the base URL via separate arguments, the `scheme` argument can be set for HTTPS connections:
8291

83-
from marklogic.client import Client
92+
from marklogic import Client
8493
client = Client(host='localhost', port='8030', scheme='https', digest=('username', 'password'), verify=False)

marklogic/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from marklogic.client import Client

marklogic/client.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def __init__(
1818
username: str = None,
1919
password: str = None,
2020
cloud_api_key: str = None,
21+
cloud_token_duration: int = 0,
2122
):
2223
super(Client, self).__init__()
2324
self.verify = verify
@@ -35,7 +36,9 @@ def __init__(
3536
elif digest:
3637
self.auth = HTTPDigestAuth(digest[0], digest[1])
3738
elif cloud_api_key:
38-
self.auth = MarkLogicCloudAuth(self.base_url, cloud_api_key, self.verify)
39+
self.auth = MarkLogicCloudAuth(
40+
self, self.base_url, cloud_api_key, cloud_token_duration
41+
)
3942
else:
4043
self.auth = HTTPDigestAuth(username, password)
4144

marklogic/cloud_auth.py

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,60 @@
1-
from urllib.parse import urljoin
2-
1+
import logging
32
import requests
3+
from requests import Response, Session, Request
44
from requests.auth import AuthBase
5+
from urllib.parse import urljoin
56

6-
# See https://requests.readthedocs.io/en/latest/user/advanced/#custom-authentication
7+
logger = logging.getLogger(__name__)
78

89

910
class MarkLogicCloudAuth(AuthBase):
10-
def __init__(self, base_url: str, api_key: str, verify):
11+
"""
12+
Handles authenticating with MarkLogic Cloud.
13+
See https://requests.readthedocs.io/en/latest/user/advanced/#custom-authentication
14+
for more information on custom authentication classes in requests.
15+
16+
Requires an instance of Session so that when a 401 is received on a request to
17+
MarkLogic - which may indicate that the token has expired - a new token can be
18+
generated and the original request can be resent using the same Session that
19+
initially sent it.
20+
"""
21+
22+
def __init__(
23+
self,
24+
session: Session,
25+
base_url: str,
26+
api_key: str,
27+
cloud_token_duration: int = 0,
28+
):
29+
self._session = session
1130
self._base_url = base_url
12-
self._verify = verify
13-
self._generate_token(api_key)
31+
self._api_key = api_key
32+
self._cloud_token_duration = cloud_token_duration
33+
self._generate_token()
34+
35+
# See https://docs.python-requests.org/en/latest/user/advanced/#event-hooks for
36+
# more information on requests hooks.
37+
self._session.hooks["response"].append(self._renew_token_if_necessary)
38+
39+
# Used for keeping track of whether a request has been resent with a new token
40+
# after receiving a 401; avoids an infinite loop of retrying requests.
41+
self.resent_request_on_401 = False
42+
43+
def __call__(self, request: Request):
44+
# Invoked via the requests authentication framework.
45+
self._add_authorization_header(request)
46+
return request
47+
48+
def _generate_token(self):
49+
params = {}
50+
if self._cloud_token_duration > 0:
51+
params["duration"] = self._cloud_token_duration
1452

15-
def _generate_token(self, api_key: str):
1653
response = requests.post(
1754
urljoin(self._base_url, "/token"),
18-
data={"grant_type": "apikey", "key": api_key},
19-
verify=self._verify,
55+
data={"grant_type": "apikey", "key": self._api_key},
56+
verify=self._session.verify,
57+
params=params,
2058
)
2159

2260
if response.status_code != 200:
@@ -26,6 +64,14 @@ def _generate_token(self, api_key: str):
2664

2765
self._access_token = response.json()["access_token"]
2866

29-
def __call__(self, r):
30-
r.headers["Authorization"] = f"Bearer {self._access_token}"
31-
return r
67+
def _renew_token_if_necessary(self, response: Response, *args, **kwargs):
68+
if response.status_code == 401 and not self.resent_request_on_401:
69+
logger.debug("Received 401; will generate new token and try request again")
70+
self.resent_request_on_401 = True
71+
self._generate_token()
72+
self._add_authorization_header(response.request)
73+
return self._session.send(response.request, *args, **kwargs)
74+
self.resent_request_on_401 = False
75+
76+
def _add_authorization_header(self, request: Request) -> None:
77+
request.headers["Authorization"] = f"Bearer {self._access_token}"

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,8 @@ black = "^23.3.0"
2222
[build-system]
2323
requires = ["poetry-core"]
2424
build-backend = "poetry.core.masonry.api"
25+
26+
[tool.pytest.ini_options]
27+
# Enables live logging; see https://docs.pytest.org/en/latest/how-to/logging.html#live-logs
28+
log_cli = 1
29+
log_cli_level = "DEBUG"

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import pytest
2-
from marklogic.client import Client
2+
from marklogic import Client
33

44

55
@pytest.fixture

tests/test_cloud.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import logging
12
import pytest
3+
import time
24

3-
from marklogic.client import Client
5+
from marklogic import Client
46

57
"""
68
This module is intended for manual testing where the cloud_config fixture
@@ -65,6 +67,29 @@ def test_invalid_api_key(cloud_config):
6567
)
6668

6769

70+
@pytest.mark.skip(
71+
"Skipped since it takes over a minute to run; comment this out to run it."
72+
)
73+
def test_renew_token(cloud_config):
74+
if cloud_config["key"] == "changeme":
75+
return
76+
77+
client = Client(
78+
host=cloud_config["host"],
79+
cloud_api_key=cloud_config["key"],
80+
cloud_token_duration=1,
81+
base_path=DEFAULT_BASE_PATH,
82+
)
83+
84+
_verify_client_works(client)
85+
86+
logging.info("Sleeping to ensure the token will have expired on the next call")
87+
time.sleep(61)
88+
89+
# First call should fail, resulting in a new token being generated.
90+
_verify_client_works(client)
91+
92+
6893
def _new_client(cloud_config, base_path: str) -> Client:
6994
return Client(
7095
host=cloud_config["host"],

0 commit comments

Comments
 (0)