Skip to content

Commit 09feaeb

Browse files
authored
Merge pull request #6 from marklogic/feature/494-cloud
DEVEXP-494 Can now connect to MarkLogic Cloud
2 parents 4e772f1 + 05e9284 commit 09feaeb

File tree

5 files changed

+170
-9
lines changed

5 files changed

+170
-9
lines changed

docs/getting-started.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,21 @@ example, just like with `requests`, a tuple can be passed to the `auth` argument
5353
from marklogic.client import Client
5454
client = Client('http://localhost:8030', auth=('username', 'password'))
5555

56+
### MarkLogic Cloud Authentication
57+
58+
When connecting to a [MarkLogic Cloud instance](https://developer.marklogic.com/products/cloud/), you will need to set
59+
the `cloud_api_key` and `base_path` arguments. You only need to specify a `host` as well, as port 443 and HTTPS will be
60+
used by default. For example:
61+
62+
from marklogic.client import Client
63+
client = Client(host='example.marklogic.cloud', cloud_api_key='some-key-value', base_path='/ml/example/manage')
64+
65+
You may still use a full base URL if you wish:
66+
67+
from marklogic.client import Client
68+
client = Client('https://example.marklogic.cloud', cloud_api_key='some-key-value', base_path='/ml/example/manage')
69+
70+
5671
## SSL
5772

5873
Configuring SSL connections is the same as

marklogic/client.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import requests
2+
from marklogic.cloud_auth import MarkLogicCloudAuth
23
from requests.auth import HTTPDigestAuth
34
from urllib.parse import urljoin
45

@@ -7,6 +8,7 @@ class Client(requests.Session):
78
def __init__(
89
self,
910
base_url: str = None,
11+
base_path: str = None,
1012
auth=None,
1113
digest=None,
1214
scheme: str = "http",
@@ -15,17 +17,25 @@ def __init__(
1517
port: int = 0,
1618
username: str = None,
1719
password: str = None,
20+
cloud_api_key: str = None,
1821
):
19-
if base_url:
20-
self.base_url = base_url
21-
else:
22-
self.base_url = f"{scheme}://{host}:{port}"
2322
super(Client, self).__init__()
2423
self.verify = verify
24+
25+
if cloud_api_key:
26+
port = 443 if port == 0 else port
27+
scheme = "https"
28+
29+
self.base_url = base_url if base_url else f"{scheme}://{host}:{port}"
30+
if base_path:
31+
self.base_path = base_path if base_path.endswith("/") else base_path + "/"
32+
2533
if auth:
2634
self.auth = auth
2735
elif digest:
2836
self.auth = HTTPDigestAuth(digest[0], digest[1])
37+
elif cloud_api_key:
38+
self.auth = MarkLogicCloudAuth(self.base_url, cloud_api_key, self.verify)
2939
else:
3040
self.auth = HTTPDigestAuth(username, password)
3141

@@ -34,15 +44,19 @@ def request(self, method, url, *args, **kwargs):
3444
Overrides the requests function to generate the complete URL before the request
3545
is sent.
3646
"""
37-
url = urljoin(self.base_url, url)
47+
if hasattr(self, "base_path"):
48+
if url.startswith("/"):
49+
url = url[1:]
50+
url = self.base_path + url
3851
return super(Client, self).request(method, url, *args, **kwargs)
3952

4053
def prepare_request(self, request, *args, **kwargs):
4154
"""
4255
Overrides the requests function to generate the complete URL before the
4356
request is prepared. See
4457
https://requests.readthedocs.io/en/latest/user/advanced/#prepared-requests for
45-
more information on prepared requests.
58+
more information on prepared requests. Note that this is invoked after the
59+
'request' method is invoked.
4660
"""
4761
request.url = urljoin(self.base_url, request.url)
4862
return super(Client, self).prepare_request(request, *args, **kwargs)

marklogic/cloud_auth.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from urllib.parse import urljoin
2+
3+
import requests
4+
from requests.auth import AuthBase
5+
6+
# See https://requests.readthedocs.io/en/latest/user/advanced/#custom-authentication
7+
8+
9+
class MarkLogicCloudAuth(AuthBase):
10+
def __init__(self, base_url: str, api_key: str, verify):
11+
self._base_url = base_url
12+
self._verify = verify
13+
self._generate_token(api_key)
14+
15+
def _generate_token(self, api_key: str):
16+
response = requests.post(
17+
urljoin(self._base_url, "/token"),
18+
data={"grant_type": "apikey", "key": api_key},
19+
verify=self._verify,
20+
)
21+
22+
if response.status_code != 200:
23+
message = f"Unable to generate token; status code: {response.status_code}"
24+
message = f"{message}; cause: {response.text}"
25+
raise ValueError(message)
26+
27+
self._access_token = response.json()["access_token"]
28+
29+
def __call__(self, r):
30+
r.headers["Authorization"] = f"Bearer {self._access_token}"
31+
return r

tests/conftest.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,27 @@ def basic_client():
1515

1616
@pytest.fixture
1717
def ssl_client():
18-
return Client(host="localhost", scheme="https", port=8031,
19-
digest=("python-test-user", "password"),
20-
verify=False)
18+
return Client(
19+
host="localhost",
20+
scheme="https",
21+
port=8031,
22+
digest=("python-test-user", "password"),
23+
verify=False,
24+
)
2125

2226

2327
@pytest.fixture
2428
def client_with_props():
2529
return Client(host="localhost", port=8030, username="admin", password="admin")
30+
31+
32+
@pytest.fixture
33+
def cloud_config():
34+
"""
35+
To run the tests in test_cloud.py, set 'key' to a valid API key. Otherwise, each
36+
test will be skipped.
37+
"""
38+
return {
39+
"host": "support.test.marklogic.cloud",
40+
"key": "changeme",
41+
}

tests/test_cloud.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import pytest
2+
3+
from marklogic.client import Client
4+
5+
"""
6+
This module is intended for manual testing where the cloud_config fixture
7+
in conftest.py is modified to have a real API key and not "changeme" as a value.
8+
"""
9+
10+
DEFAULT_BASE_PATH = "/ml/test/marklogic/manage"
11+
12+
13+
def test_base_path_doesnt_end_with_slash(cloud_config):
14+
if cloud_config["key"] == "changeme":
15+
return
16+
17+
client = _new_client(cloud_config, DEFAULT_BASE_PATH)
18+
_verify_client_works(client)
19+
20+
21+
def test_base_path_ends_with_slash(cloud_config):
22+
if cloud_config["key"] == "changeme":
23+
return
24+
25+
client = _new_client(cloud_config, DEFAULT_BASE_PATH + "/")
26+
_verify_client_works(client)
27+
28+
29+
def test_base_url_used_instead_of_host(cloud_config):
30+
if cloud_config["key"] == "changeme":
31+
return
32+
33+
base_url = f"https://{cloud_config['host']}"
34+
client = Client(
35+
base_url, cloud_api_key=cloud_config["key"], base_path=DEFAULT_BASE_PATH
36+
)
37+
_verify_client_works(client)
38+
39+
40+
def test_invalid_host():
41+
with pytest.raises(ValueError) as err:
42+
Client(
43+
host="marklogic.com",
44+
cloud_api_key="doesnt-matter-for-this-test",
45+
base_path=DEFAULT_BASE_PATH,
46+
)
47+
assert str(err.value).startswith(
48+
"Unable to generate token; status code: 403; cause: "
49+
)
50+
51+
52+
def test_invalid_api_key(cloud_config):
53+
if cloud_config["key"] == "changeme":
54+
return
55+
56+
with pytest.raises(ValueError) as err:
57+
Client(
58+
host=cloud_config["host"],
59+
cloud_api_key="invalid-api-key",
60+
base_path=DEFAULT_BASE_PATH,
61+
)
62+
assert (
63+
'Unable to generate token; status code: 401; cause: {"statusCode":401,"errorMessage":"API Key is not valid."}'
64+
== str(err.value)
65+
)
66+
67+
68+
def _new_client(cloud_config, base_path: str) -> Client:
69+
return Client(
70+
host=cloud_config["host"],
71+
cloud_api_key=cloud_config["key"],
72+
base_path=base_path,
73+
)
74+
75+
76+
def _verify_client_works(client):
77+
# Verify that the request works regardless of whether the path starts with a slash
78+
# or not.
79+
_verify_search_response(client.get("v1/search?format=json"))
80+
_verify_search_response(client.get("/v1/search?format=json"))
81+
82+
83+
def _verify_search_response(response):
84+
assert 200 == response.status_code
85+
assert 1 == response.json()["start"]

0 commit comments

Comments
 (0)